Skip to content

[3.0] Renders the templates with Plates (part 2 of 2) - #9728

Open
albertlast wants to merge 20 commits into
SimpleMachines:release-3.0from
albertlast:3.0/plates
Open

albertlast wants to merge 20 commits into
SimpleMachines:release-3.0from
albertlast:3.0/plates

Conversation

@albertlast

Copy link
Copy Markdown
Collaborator

Description

This replaces SMF's own template mechanism, template_*() functions that echo, with Plates (league/plates 3.6), and converts every template in the default theme. The forum renders what it rendered before. The one exception is the theme copy page, which had to learn the new layout.

This is stacked on #9727 (part 1, the converter), which is stacked on #9712 (the snapshot tests). Until both merge, this PR's diff includes their commits. The ones that belong here start at "Renders a sub-template with Plates when its theme has one".

How to review it

The diff is large, but almost all of it is generated: 54 *.template.php files deleted and 342 Plates files added under Themes/default/templates/. Those are the converter's output (part 1), with one exception, Stats, which was converted by hand as the first test of the engine. The generated files are reproducible from a clean checkout:

.dev/convert-templates.sh --from ea5ae213e --all --skip Stats

The part written by hand is about 700 lines in Sources/, plus tests:

File What
Sources/TemplateEngine.php wraps a Plates Engine with SMF's theme inheritance
Sources/Theme.php loadTemplate() / loadSubTemplate() render either kind
Sources/TemplateFunctions.php the template_*() helpers that mods call directly, kept working
Sources/Actions/Admin/Themes.php, ACP.php, ThemeChooser.php the theme admin pages learn the new layout
ServerSideIncludes.php, WatchedUsers.php, Permissions.php three direct calls to template functions
tests/Unit/TemplateEngineTest.php, tests/Integration/Http/ThemeCopyTest.php tests
Design
  • Layout. A template is a directory: Themes/<theme>/templates/<Template>/<sub_template>.php. Theme::loadTemplate('Display') finds the directory, and Theme::loadSubTemplate('main') renders Display/main.php.
  • Inheritance, per file. Plates' theme hierarchy follows template_dirs: current theme, then the base theme, then default. A theme overrides a single sub-template by shipping that one file, where it used to copy the whole *.template.php.
  • Both kinds at once. Themes that aren't converted keep working unchanged. The first theme directory that has a template decides its kind. Where both kinds define the same sub-template, the template loaded last wins.
    • A converted template calls another sub-template through $this->subTemplate('name', [...]), which works whichever kind has it. Two siblings support that call: $this->fetchSubTemplate() returns the output instead of printing it, and $this->hasSubTemplate() checks whether a sub-template exists.
  • Mods. The helpers mods call from their own templates are still defined as functions when their template loads, and each renders its Plates file. They are template_button_strip(), template_quickbuttons(), template_menu(), theme_linktree(), template_show_list(), template_control_richedit(), template_control_richedit_buttons() and template_control_verification().
  • Data. Templates still read Utils::$context, Config, Lang and friends, as before. Passing each template its data explicitly is the natural next step, and a separate one: it would change every file by hand and lose the byte-for-byte comparison that makes this PR checkable.
  • Plates bug worked around. With Engine::fromTheme(), Engine::exists() and Engine::path() ignore the theme hierarchy, so the engine goes through Template::exists() / path() instead.
Backwards compatibility
  • Custom themes with *.template.php files: unchanged.
  • Mods that call the helpers above: unchanged, as are mods that define their own template_*() functions and load them through loadTemplate().
  • Mods that patch Themes/default/*.template.php through the package manager break. The files they search no longer exist, and there's no faithful way to redirect a search-and-replace into the new files, which use different syntax. Such mods need updating for 3.0 anyway, but this makes it certain, and it probably deserves a note for mod authors.
Verified
  • Page by page, on both engines, with part 1's capture and comparison. Before the final tidying commit, 460 of 464 pages were byte-identical to the old templates. The other four were the theme copy page, changed on purpose, and PostgreSQL tie ordering. After tidying, every page renders the same (--rendered). The only differences are ties in sort order: the stats top tens ([3.0] Ranks ties in the stats top tens in a defined order #9722) and the admin log ([3.0] Lists tied moderation log entries in a defined order #9726).
  • What the snapshots don't cover: ssi_examples.php, XML responses, and a probe calling the helpers above the way a mod does. All identical.
  • Suites: unit, integration, HTTP and snapshot all pass on MySQL and PostgreSQL (691 tests). TemplateEngineTest adds 20 unit tests for the engine: inheritance, mixed kinds, which one wins, argument passing, and the legacy helpers.
Overlap with other PRs

Issues References (Fixes|Related|Closes)

Related: #9712, #9727 (part 1 of 2), #9717.

🤖 Generated with Claude Code

@jdarwood007 jdarwood007 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I only clicked around and didn't even determine whether this is what we are doing.

I want to see how this handles compatibility with 2.1. It is understood that because we have changed CSS, we may have some presentation issues (we should try to limit those). But being able to call the theme functions and methods (including JS) should still work.


if ($output_method == 'echo') {
template_menu();
Theme::loadSubTemplate('menu');

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We would need a compat function here.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

template_menu() still exists as a function. Sources/TemplateFunctions.php defines it whenever the index template is loaded from Plates, together with template_button_strip(), template_quickbuttons(), theme_linktree(), template_show_list() and the three GenericControls functions. Each of them renders its Plates file, so a mod that calls template_menu() directly keeps working.

This line only changes how SSI itself renders the menu. Theme::loadSubTemplate('menu') renders whichever kind the theme has, so a theme with its own index.template.php still gets its own template_menu(). ssi_examples.php, plus a script that calls ssi_menubar(), template_button_strip(), template_quickbuttons() and theme_linktree() the way a mod would, render byte-identically before and after this PR (.dev/plates/probe.php in #9727).

@albertlast

Copy link
Copy Markdown
Collaborator Author

Thanks for looking. I agree 2.1 compatibility needs an answer, but I think the answer has to account for where the templates are going next, not only for this PR.

What this PR keeps working

  • Themes with *.template.php files load as before, and a theme can mix both kinds: override one template the old way and inherit the rest from Plates.
  • The helpers that templates and mods call across files still exist as functions (see the inline reply).
  • No JS, no CSS, and no Theme method besides loadTemplate()/loadSubTemplate() changes here. Whatever a theme calls in those works exactly as it does on release-3.0.

A gap I found while checking
2.1.7's default theme makes three cross-file template calls that have no compatibility function yet:

  • template_topic_legend(), called from Recent, defined in MessageIndex
  • template_options(), called from Profile, defined in Settings
  • template_admin_quick_search(), called from GenericMenu, defined in Admin

So a 2.1 theme that overrides Recent.template.php would hit an undefined function.

Why I'd rather not chase full 2.1 theme compatibility
Every compatibility function freezes an old signature and keeps a second rendering path alive. That's affordable once. But the gap to 2.1 won't stay where it is:

  • 3.0 has already changed the CSS.
  • The next step we have in mind is moving the inline JS out of the templates. Today's templates, and 2.1's, set variables and wire up behaviour inline; after that step they won't, and a 2.1 template that relies on them breaks regardless of Plates.
  • After that, passing templates their data explicitly instead of reading Utils::$context widens the gap again.

Runtime compatibility with 2.1 themes would mean rebuilding a compatibility layer at each of those steps, and each step makes the next one harder. Over time it costs more and delivers less.

What I'd propose instead

  • For theme authors, a migration path rather than runtime compatibility. The converter in [3.0] Adds a repeatable converter from template functions to Plates templates (part 1 of 2) #9727 (.dev/plates/convert.php) converts any theme's *.template.php files to Plates, not only the default theme's. It translates the syntax mechanically; adapting to 3.0's markup, CSS and JS is still the author's job, as it would be anyway.
  • For mods, a small, fixed set of helpers. These are the functions mods call from their own templates: the ones above, plus the three missing ones if we want them. All of it lives in Sources/TemplateFunctions.php, marked deprecated with a removal target. Taking it out later is then a deletion, not a refactor. The list can't grow, because it's bounded by 2.1.7.
  • For JS, a decision in the PR that moves it out. The global function names (smc_Toggle, smf_* and so on) can stay as they are, which is cheap. The inline variables are what 2.1 code depends on, and that PR is where to decide what happens to them.

Since this decides more than this PR (it applies equally to the CSS work and the JS extraction), could we settle how much 2.1 theme compatibility 3.0 promises before I add more compatibility code here? If the answer is "mods yes, whole themes no", I'll add the three missing helpers and stop there.

albertlast and others added 20 commits October 5, 2026 06:41
Records what about 230 pages render today, as a guest, a member and an
administrator, so that moving the templates to Plates can be checked page
by page. A page that renders differently fails, and the diff says how.

Pages are compared as DomSnapshot describes them rather than as HTML:
whitespace, comments and attribute order are dropped, and the forum's
own data - ids, counts, dates, names, sessions, list lengths - is
masked. Pages that list content look at fixtures built once per forum,
and lists of the whole forum are narrowed to them, so the snapshots do
not change when somebody posts, registers or adds a board. The errors a
page logs are part of its snapshot.

perturb-forum.php fills a throwaway forum with unrelated members, boards
and posts. Recorded on a fresh MySQL forum, the snapshots pass there and
on a fresh PostgreSQL forum, and on both again after two rounds of it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: albertlast <mathiaspapealbert@hotmail.com>
…d-in forum showed

HttpTestCase slept for three seconds and retried whenever flood control
turned a login or a post away. As suggested in review, submitForm() now
deletes the rows in log_floodcontrol first, so there is nothing to wait
out and no test depends on how quickly it runs. The retry logic goes.

Running the snapshots against a forum that had been used, rather than a
new one, found content they still depended on:

- the forum administrator's own profile, since the fixtures posted as
  them; a fixture administrator posts instead, and the fixtures carry a
  version so that forums holding the old ones rebuild them
- the hash in a minified file's name, which \b never matched after "_"
- ;boardseen, decimals and thousands in numbers, email addresses, the
  encoded parameters of a search, and fixture ids in the selectors
- the errors a member had logged, on their tracking page

The recording itself had two faults: a Settings_bak.php the forum could
not write, which put a warning on every settings page, and the password
prompt in front of a member's account settings, which was recorded in
place of the page. The suite now gets past the prompt for members as it
already did for the administrator.

Snapshot tests also reuse their signed in browser rather than making a
new one per test, which cost a request each and a third of the run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: albertlast <mathiaspapealbert@hotmail.com>
docker compose exec starts a fresh environment, so test.sh only hands over
the variables it names. SMF_UPDATE_SNAPSHOTS was not one of them, and under
the docker runner the documented way to re-record the snapshots compared
against them instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: albertlast <mathiaspapealbert@hotmail.com>
The search results snapshots open search2 from a link, and SMF turns away a
search made within a few seconds of the last one. With each request taking
about a second, as it does over a Windows bind mount, the two search pages
were never close enough together to notice. On a Linux filesystem the suite
runs about twenty times faster, and the second search came back as a 403.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: albertlast <mathiaspapealbert@hotmail.com>
profile_track_ip pinned a fatal that SimpleMachines#9718 has fixed, so it now records the
page. features_basic and profile_theme show the time zone select grouped by
country. themes_copy lists the new TimeZoneSelect template and no longer
logs an array to string conversion, which SimpleMachines#9720 fixed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: albertlast <mathiaspapealbert@hotmail.com>
Adds the license header to any test file this adds, and writes the
docblock of each new test method in the agreed shape: Expected:, Guards:
for a regression, and @link lines for the commit that introduced the bug
and for this pull request.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: albertlast <mathiaspapealbert@hotmail.com>
HttpTestCase now shares one guest browser between tests, and
signInAsAdmin() signs in a browser of its own. The snapshot classes
need a browser per audience, so their first browser is a new one rather
than the shared guest, which the member audience would otherwise sign
in for every test after it. The admin audience signs in a fresh browser
rather than the shared one, since passing the admin security check
changes its session, and the audience's browser is kept once signing in
is done.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: albertlast <mathiaspapealbert@hotmail.com>
The time zone pickers on the basic features page and the theme profile
page name and order each zone by whether daylight saving is in effect
that day, so both snapshots failed when Australia moved its clocks on
4 October, with no template involved. Both cases now ignore the select,
which the snapshot still records as being there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: albertlast <mathiaspapealbert@hotmail.com>
With SMF_SNAPSHOT_RAW set, every page the suite compares is also saved as it
was sent, one directory per engine. The snapshots leave whitespace out on
purpose; comparing two of these captures after masking sessions, tokens and
numbers shows whether a converted template renders byte for byte what the
old one did.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: albertlast <mathiaspapealbert@hotmail.com>
.dev/convert-templates.sh converts the default theme's *.template.php files
into one Plates file per sub-template, starting again from the old files as a
commit has them on every run. .dev/capture-pages.sh saves every page the
snapshot suite renders, on both engines, plus SSI, XML responses and a probe
calling the template helpers the way a mod does; .dev/compare-pages.sh diffs
two captures byte for byte, or as a browser renders them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: albertlast <mathiaspapealbert@hotmail.com>
Adds league/plates, and SMF\TemplateEngine to hold it. A theme keeps its
Plates templates in templates/<Template>/<sub_template>.php, and
Theme::loadTemplate() and Theme::loadSubTemplate() render those when the
theme has them, falling back to the template_*() functions otherwise. So
the templates can move over one at a time.

Each file is looked for in the current theme, then the base theme, then
the default theme, so a theme can override one sub-template and inherit
the rest. Where a Plates template and a template_*() function share a
sub-template name, the one whose template was loaded last runs, as the
function would have before.

The copy page in the theme admin copies a Plates template's directory
whole, and the version page lists each of its files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: albertlast <mathiaspapealbert@hotmail.com>
Stats.template.php becomes templates/Stats/main.php. The markup and the
text are unchanged; only the whitespace between them differs, and none of
it is where a browser would show it.

The copy page now lists the template as templates/Stats/, and copies it
into a theme as a directory.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: albertlast <mathiaspapealbert@hotmail.com>
A template that calls another sub-template should not have to know whether
that one has been converted to Plates yet. TemplateEngine::renderSubTemplate()
renders whichever kind a loaded template has, with named parameters for a
Plates template and named or positional ones for a template_*() function,
and hasSubTemplate() answers function_exists() for both. A Plates template
reaches them as $this->subTemplate() and $this->hasSubTemplate().

Theme::loadSubTemplate() goes through the same method, so there is one place
that decides which kind runs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: albertlast <mathiaspapealbert@hotmail.com>
Converted mechanically from the template_*() functions, one file per
sub-template. On every page the snapshot suite covers, on both engines, the
HTML is the same byte for byte as before, apart from the theme copy page,
which now lists these as templates/<Name>/.

Calls between sub-templates go through $this->subTemplate(), so they work
whether the sub-template called has been converted or not. The templates
converted here are the ones nothing still unconverted calls into.

The theme reset page lists a theme only if it has its own Settings template,
and now counts a Plates one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: albertlast <mathiaspapealbert@hotmail.com>
Some template_*() functions return their HTML rather than output it, for a
caller that needs a string: the quick buttons in 'return' mode, and the
watched users list's callback. fetchSubTemplate() gets that string from
either kind of sub-template, including what a function returns.

Parameters are named for Plates templates. A function a mod provides may
call its own something else, and named arguments it does not know would be
an error, so a function whose parameters do not match gets them in order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: albertlast <mathiaspapealbert@hotmail.com>
…Plates

Admin, BoardIndex, EventEditor, ManagePermissions, MessageIndex,
ModerationCenter and Profile, and TimeZoneSelect, which Admin, EventEditor
and Profile call, converted the same way, and each byte for byte the same
on every page the snapshot suite covers.

Two places in Sources called a template function directly and now go
through the engine: the watched users list's column callback, which needs
the HTML as a string, and the inline permissions on settings pages.

A template call used as a value, rather than as a statement of its own, now
uses fetchSubTemplate(); the recent posts pages had four, which worked only
because button_strip outputs as it goes. A call through a name built at
runtime names its parameters after the sub-templates the name can reach.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: albertlast <mathiaspapealbert@hotmail.com>
GenericList and GenericControls, which every other converted template
calls into for lists, the editor and the verification controls. Byte for
byte the same on every page the snapshot suite covers.

template_control_verification() returned whether there was more to show
when displaying one item at a time. Nothing in SMF uses that, and a Plates
template cannot return a value; the output is the same.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: albertlast <mathiaspapealbert@hotmail.com>
The last two. The default theme has no *.template.php left, and every page
the snapshot suite covers renders byte for byte what it did.

Mods and themes call template_button_strip(), template_quickbuttons(),
template_menu(), theme_linktree(), template_show_list() and the editor and
verification controls by name, from templates of their own. Loading the
Plates template that holds one of them defines that function, rendering the
Plates template, unless the name is already taken by a theme that still has
the template as a file. The XML template's stand-ins for when there is no
index template are defined the same way.

theme_linktree() is the sub-template 'linktree'. The quick buttons are
always output, and template_quickbuttons() returns them when asked, as it
did.

The admin panel read a theme's variants out of index.template.php, and
checked for that file to tell whether a directory is a theme; both also look
at templates/index/init.php. Creating a copy of the default theme copies its
index template directory. SSI's menu bar renders the sub-template rather
than calling the function.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: albertlast <mathiaspapealbert@hotmail.com>
PHP swallows the newline that follows a closing tag, so the conversion kept
each one by leaving an empty line after it. Where the next line is indented,
that newline and the indentation collapse to the same single space in HTML,
so the empty line only costs readability. Inside <script>, <pre> and
<textarea> the newline is content or a statement boundary, and stays.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: albertlast <mathiaspapealbert@hotmail.com>
Adds the license header to any test file this adds, and writes the
docblock of each new test method in the agreed shape: Expected:, Guards:
for a regression, and @link lines for the commit that introduced the bug
and for this pull request.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: albertlast <mathiaspapealbert@hotmail.com>
live627 pushed a commit that referenced this pull request Oct 8, 2026
Each test method now has the docblock AGENTS.md describes: a summary,
Expected: with the call and the result its assertions check, and for a
regression test, Guards: with what went wrong, a @link to the commit that
introduced the bug and a @link to the issue or pull request that fixed it.

The Introduced by line is left out where the faulty code is older than
the history (it is already in the 2012 import) or where the commit could
not be confirmed from its diff. A comment at the top of a test body that
explained why the test exists has moved into the docblock; nothing else
in the bodies has changed.

ThemeCopyTest is left alone, because #9728 rewrites it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: albertlast <mathiaspapealbert@hotmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants