Repository source: docs/plans/archive/multi_site_config_step4_implementation_summary.md
Multi-Site Config — Step 4: Implementation Summary
Stage 1 - SiteProfileRegistry class
Changes:
Added
src/ForumRewrite/SiteProfileRegistry.phpall(): array—zenmemesandchouseentries, each withname,defaultTheme,composerPromptactive(): array— resolvesgetenv('FORUM_SITE_ID'), defaults tozenmemeswhen unset/empty/unrecognized
Added
tests/SiteProfileRegistryTest.phpcovering default resolution, known override, unknown-value fallback, and required-field presenceRegistered the new test file in
tests/run.php
Verification:
php tests/run.php— full suite passes, including all 4 newSiteProfileRegistryTestcases
Notes:
Both profiles currently carry identical
defaultTheme/composerPromptplaceholder values; Stages 3-4 wire those fields through to rendering, at which point chouse's values can be revisited/diverged if desired
Stage 2 - Wire SiteConfig::siteName()
Changes:
SiteConfig::SITE_NAMEconstant replaced withSiteConfig::siteName(): string, delegating toSiteProfileRegistry::active()['name']Updated call sites:
Application.php:966,997,2346,2422,Host/FrontController.php:265,278,View/TemplateRenderer.php:66templates/pages/about.phpneeded no change — it already reads$siteNamefrom the template context these call sites populate
Verification:
php -lon all four touched files — no syntax errorsphp tests/run.php— full suite passesManual check:
SiteConfig::siteName()returnszenmemeswithFORUM_SITE_IDunset,chousewhen set tochouse, andzenmemesfor an unrecognized value
Notes:
Searched for other hardcoded
zenmemesliterals outside theSITE_NAMEcall sites. Found and intentionally left untouched, as out of scope for this stage:FrontController.php:281— a hardcoded "zenmemes is still baking" joke string in the busy-page copy (site-specific copy, not the site-name accessor; belongs to a future chouse-branding feature)templates/layout.php,public/assets/theme_toggle.js,public/assets/thread_density_toggle.js—localStoragekey names (zenmemes-theme,zenmemes-thread-density); internal storage keys, not user-facing branding, and functionally inert across sites since browsers scopelocalStorageper origintemplates/pages/about.php:3— adata-about-section="zenmemes"attribute used for CSS/JS targeting, not rendered text
Stage 3 - Default theme wiring
Changes:
TemplateRenderer::renderLayout()adds'defaultTheme' => SiteProfileRegistry::active()['defaultTheme']to the layout contexttemplates/layout.phprenders it asdata-default-theme="..."on the<html>root elementpublic/assets/theme_toggle.js: newdefaultTheme()helper reads that attribute (validated against the known theme list, else"auto");readStoredTheme()'s two"auto"fallbacks now call it instead
Verification:
php -lon touched PHP files,node -contheme_toggle.js— no syntax errorsphp tests/run.php— full suite passesManual check via
TemplateRenderer::renderLayout():FORUM_SITE_IDunset rendersdata-default-theme="auto"+siteName=zenmemes;FORUM_SITE_ID=chouserendersdata-default-theme="auto"+siteName=chouse(both profiles currently share theautodefault, so output differs only in site name until a profile'sdefaultThemediverges)
Notes:
The inline pre-hydration script in
layout.php(head<script>, lines 7-34) still falls back to leaving nodata-themeattribute when nothing is stored, rather than readingdata-default-themeitself — for every profile today that resolves to the same visual result (autovia CSSprefers-color-scheme), since both profiles useauto. If a future profile sets a non-autodefault theme, first-time visitors would see a brief flash of the system-preference theme beforetheme_toggle.jsapplies the real default onDOMContentLoaded. Left out of this stage's scope (the approved plan only called for wiringTemplateRenderer→ attribute →theme_toggle.js); worth revisiting if/when a site profile actually diverges fromauto.
Stage 4 - Composer copy wiring
Changes:
TemplateRenderer::renderFile()adds'composerPrompt' => SiteProfileRegistry::active()['composerPrompt']to the same default-merge block that already injectsunicodeAuthoredTextEnabled/emojiAuthoredTextEnabledinto every page/partial, so no per-route wiring was neededtemplates/pages/board.php: the inline composer'splaceholderandaria-labelnow read$composerPrompt(aria-label strips a trailing.for phrasing) instead of the literal"Start a thread..."/"Start a thread"strings
Verification:
php -lon touched PHP files — no syntax errorsphp tests/run.php— full suite passesManual render check via
TemplateRenderer::renderPageTemplate('board.php', ...): output textarea showsplaceholder="Start a thread..."andaria-label="Start a thread", identical to the pre-change literal, confirming no behavior change for zenmemesGrepped
templates/for other occurrences of"Start a thread"— none found, so no duplicate copy was missed
Notes:
Both profiles still share the same
composerPromptvalue from Stage 1's placeholder data; chouse's copy can diverge later (e.g. in a future chouse-branding feature) by editing onlySiteProfileRegistry::all()
Stage 5 - Site-scoped local dev bootstrap
Changes:
LocalRepositoryBootstrap::defaultRepositoryRoot(string $projectRoot, string $siteId = 'zenmemes'): string— new optional$siteIdparam;zenmemes(including the pre-existing default) resolves to the unchangedstate/local_repositorypath, any other site ID resolves to a suffixed sibling directory (state/local_repository_<siteId>), auto-initialized from the same fixture the same waypublic/index.phpresolves$siteIdviaSiteProfileRegistry::active()['name']and applies the same suffix convention to the local defaults forFORUM_REPOSITORY_ROOT,FORUM_DATABASE_PATH, andFORUM_STATIC_HTML_ROOT(all three only take effect when the corresponding env var is unset — explicit env vars still win, unchanged)All other
LocalRepositoryBootstrap::defaultRepositoryRoot()call sites (variousscripts/*.php,tests/LocalAppSmokeTest.php) are unaffected — the new parameter defaults to'zenmemes', reproducing today's exact behavior
Verification:
php -lon touched PHP files — no syntax errorsphp tests/run.php— full suite passesManual check:
LocalRepositoryBootstrap::defaultRepositoryRoot($projectRoot)(no site ID) still resolves to the pre-existingstate/local_repositoryManual check:
LocalRepositoryBootstrap::defaultRepositoryRoot($projectRoot, 'chouse')auto-initializes a distinctstate/local_repository_chousewith its ownrecords/and.gitManual check of the
public/index.phppath-resolution logic:FORUM_SITE_IDunset yields the exact pre-existing database/static-html paths;FORUM_SITE_ID=chouseyields distinct_chouse-suffixed sibling pathsThis directly delivers the dev-config ask:
FORUM_SITE_ID=chouse ./v3 startalone (no otherFORUM_*vars) now serves a fully separate, auto-initialized chouse sandbox — content, database, and static-artifact paths never overlap with the zenmemes checkout
Notes:
state/local_repository_chouseis gitignored (same rule that coversstate/local_repository), so exercising this locally leaves no untracked changes to commit
Stage 6 - Docs and smoke-test coverage
Changes:
docs/examples/apache_vhost.conf: added a commentedFORUM_SITE_IDline alongside the existingFORUM_*path varsdocs/examples/env.production.example: same, as a commented optional var with its default noteddocs/runbooks/production_deploy.md: new "Site Profile" section — values, default, precedence/rollback matching the runbook's existingFORUM_*style, and the local/CLIFORUM_SITE_ID=chouse ./v3 startdev workflow from Stage 5tests/LocalAppSmokeTest.php: addedtestDefaultRepositoryBootstrapIsSiteScoped()(zenmemes path unchanged, chouse resolves to a distinct auto-initialized sibling) andtestBoardPageRendersActiveSiteProfilePerFormSiteId()(fullApplication-level render confirms site name and default theme both flip correctly betweenFORUM_SITE_IDunset andchouse, with properputenvcleanup infinally)
Verification:
php -lon all touched PHP files — no syntax errorsphp tests/run.php— full suite passes, including both newLocalAppSmokeTestcases
Notes:
While verifying this stage,
php tests/run.phpinitially reportedtestFrontControllerShowsBusyErrorForExecutionLockContentionfailing. Root cause: an untrackedpublic/index.html(gitignored static-artifact cache) had been written to disk as a side effect of this session's own earlier manualApplication-level rendering checks in Stages 3-5.FrontControllerserves that cached file for/before ever reaching the lock/rebuild code path the test exercises, so the test short-circuited into a false failure — not a regression from this feature's changes. Confirmed by running the full suite on a cleanmainworktree (passed) and by inspectingFrontController::resolveStaticArtifactPath(), which checkspublicRoot/index.htmlfirst. Fixed by deleting the stray gitignored file; the full suite is green afterward.