From 75ec902935fcdbe7cb8c9fd9dd89ebaf01a5e722 Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Wed, 9 Sep 2026 15:22:34 -0500 Subject: [PATCH 001/100] fix(scripts): keep generated audit JSON output parseable --- .../scripts/generated-audit-wrappers.test.cjs | 156 ++++++++++++++++++ docs/generated-child-theme-contract.md | 14 ++ package.json | 1 + whisk/docs/development.md | 33 ++++ whisk/package.json | 4 +- 5 files changed, 206 insertions(+), 2 deletions(-) create mode 100644 .github/scripts/generated-audit-wrappers.test.cjs diff --git a/.github/scripts/generated-audit-wrappers.test.cjs b/.github/scripts/generated-audit-wrappers.test.cjs new file mode 100644 index 0000000..d9481a9 --- /dev/null +++ b/.github/scripts/generated-audit-wrappers.test.cjs @@ -0,0 +1,156 @@ +#!/usr/bin/env node + +// Opt-in integration: generate the real child theme and install a packed Core +// artifact. No network installation is added to the ordinary contract tests. +const assert = require('node:assert/strict'); +const { spawnSync } = require('node:child_process'); +const { createHash } = require('node:crypto'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { test } = require('node:test'); + +const repoRoot = path.resolve(__dirname, '../..'); +const excluded = new Set(['.git', 'node_modules', 'dist', '.out', '.coverage', '.cache']); +const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm'; +const npx = process.platform === 'win32' ? 'npx.cmd' : 'npx'; + +function writeJson(file, value) { + fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`); +} + +function run(command, args, cwd, env) { + const result = spawnSync(command, args, { + cwd, + env, + encoding: 'utf8', + timeout: 180000, + maxBuffer: 8 * 1024 * 1024, + }); + assert.ifError(result.error); + assert.equal(result.signal, null, `Unexpected signal: ${result.signal}`); + return result; +} + +function assertWrapper(result, direct, footer) { + assert.equal(result.status, direct.status, 'Wrapper must preserve the exact Core exit status.'); + let report; + assert.doesNotThrow(() => { + // Parsing a substring or dropping a footer would conceal this regression. + report = JSON.parse(result.stdout); + }, 'The entire stdout stream must be valid JSON.'); + assert.deepEqual(report, JSON.parse(direct.stdout), 'Wrapper must forward every argument unchanged.'); + assert.equal(report.schemaVersion, 1); + assert.equal(report.tool.name, '@emulsify/core'); + assert.equal(result.stderr, `${direct.stderr}\n${footer}\n`, 'The exact documentation footer belongs on stderr.'); + assert.ok(!result.stdout.includes(footer), 'Documentation footer must not enter stdout.'); +} + +test('real generated audit wrappers retain the packed Core CLI contract', { timeout: 240000 }, async (t) => { + assert.ok(process.env.EMULSIFY_CORE_TARBALL, 'Set EMULSIFY_CORE_TARBALL to a local npm pack .tgz artifact.'); + const tarball = path.resolve(process.env.EMULSIFY_CORE_TARBALL); + const tarballHash = createHash('sha256').update(fs.readFileSync(tarball)).digest('hex'); + const workRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'emulsify-generated-audit-')); + const consumer = path.join(workRoot, 'generated child with spaces'); + const env = { + ...process.env, + CI: '1', + FORCE_COLOR: '0', + PUPPETEER_SKIP_DOWNLOAD: 'true', + npm_config_cache: process.env.npm_config_cache || path.join(workRoot, 'npm-cache'), + }; + + try { + fs.cpSync(path.join(repoRoot, 'whisk'), consumer, { + recursive: true, + filter: (source) => !excluded.has(path.basename(source)), + }); + const projectPath = path.join(consumer, 'project.emulsify.json'); + const project = JSON.parse(fs.readFileSync(projectPath, 'utf8')); + Object.assign(project.project, { + name: 'Audit Fixture', machineName: 'audit-fixture', description: 'Generated audit wrapper contract.', + }); + writeJson(projectPath, project); + const generation = run(process.execPath, [path.join(consumer, '.cli/init.js')], consumer, env); + assert.equal(generation.status, 0, generation.stderr); + const packagePath = path.join(consumer, 'package.json'); + const generated = JSON.parse(fs.readFileSync(packagePath, 'utf8')); + const source = JSON.parse(fs.readFileSync(path.join(repoRoot, 'whisk/package.json'), 'utf8')); + assert.deepEqual(generated.scripts, source.scripts, 'Generation must retain the real starter scripts.'); + const install = run(npm, ['install', '--ignore-scripts', '--no-audit', '--no-fund', '--package-lock=false', '--save-exact', tarball], consumer, env); + assert.equal(install.status, 0, install.stderr); + const installed = JSON.parse(fs.readFileSync(packagePath, 'utf8')); + assert.deepEqual(installed.scripts, generated.scripts); + const coreRoot = path.join(consumer, 'node_modules/@emulsify/core'); + assert.equal(fs.lstatSync(coreRoot).isSymbolicLink(), false, 'Use an installed package, not a source symlink.'); + const coreVersion = JSON.parse(fs.readFileSync(path.join(coreRoot, 'package.json'), 'utf8')).version; + t.diagnostic(JSON.stringify({ coreVersion, tarballSha256: tarballHash, generatedScripts: { audit: generated.scripts.audit, 'audit:twig-stories': generated.scripts['audit:twig-stories'] } })); + + const auditRoot = path.join(workRoot, 'audit root with spaces'); + const component = path.join(auditRoot, 'src/components/card'); + fs.mkdirSync(component, { recursive: true }); + writeJson(path.join(auditRoot, 'project.emulsify.json'), { project: { platform: 'none' } }); + fs.writeFileSync(path.join(component, 'card.twig'), '

{{ title }}

'); + fs.writeFileSync(path.join(component, 'card.stories.js'), 'import cardTwig from "./card.twig";\nexport const Card = (args) => cardTwig(args);\n'); + const cleanRoot = path.join(workRoot, 'clean root with spaces'); + const cleanComponent = path.join(cleanRoot, 'src/components/clean'); + fs.mkdirSync(cleanComponent, { recursive: true }); + writeJson(path.join(cleanRoot, 'project.emulsify.json'), { project: { platform: 'none' } }); + fs.writeFileSync(path.join(cleanComponent, 'clean.stories.js'), 'export default { title: "Audit/Clean" };\nexport const Clean = () => "

Clean

";\n'); + + for (const [script, executable, footer, threshold] of [ + ['audit', 'audit.js', 'Audit docs: https://github.com/emulsify-ds/emulsify-core/blob/4.x/docs/migration-4x.md#storybook-migration', ['--fail-on', 'warn']], + ['audit:twig-stories', 'audit-twig-stories.js', 'Migration docs: https://github.com/emulsify-ds/emulsify-core/blob/4.x/docs/storybook.md#legacy-twig-story-compatibility', ['--fail-on-found']], + ]) { + const args = ['--root', auditRoot, '--json']; + const directRun = (options) => run(path.join(coreRoot, 'scripts', executable), options, consumer, env); + const wrapperRun = (options) => run(npm, ['run', script, '--silent', '--', ...options], consumer, env); + for (const [scenario, options, expectedStatus] of [ + ['clean scan', ['--root', cleanRoot, '--json', ...threshold], 0], + ['advisory findings', args, 0], + ['findings fail threshold', [...args, ...threshold], 1], + ['invalid argument', [...args, '--unknown-wrapper-option'], 2], + ]) { + await t.test(`${script}: ${scenario}`, () => { + const direct = directRun(options); + assert.equal(direct.status, expectedStatus, direct.stderr); + const report = JSON.parse(direct.stdout); + if (scenario === 'clean scan') { + assert.deepEqual(report.findings, []); + } else if (expectedStatus !== 2) { + assert.ok(report.findings.some((finding) => finding.id === 'legacy-twig-story' && finding.path === 'src/components/card/card.stories.js' && finding.line === 2 && finding.severity === 'warn')); + } + const wrapped = wrapperRun(options); + assertWrapper(wrapped, direct, footer); + const executableName = executable === 'audit.js' ? 'emulsify-audit' : 'emulsify-audit-twig-stories'; + const directAlias = run(npx, ['--no-install', executableName, ...options], consumer, env); + assert.equal(directAlias.status, expectedStatus); + assert.deepEqual(JSON.parse(directAlias.stdout), report); + assert.equal(directAlias.stderr, direct.stderr); + t.diagnostic(JSON.stringify({ script, scenario, options, directStatus: direct.status, wrapperStatus: wrapped.status, npxNoInstallStatus: directAlias.status, wholeStdoutJson: true, footerOnStderr: true })); + }); + } + + for (const [scenario, mutate, expectedFailure] of [ + ['rejects stdout footer', (command) => command.replace(' >&2;', ';'), /entire stdout stream/], + ['rejects masked status', (command) => command.replace('exit $status', 'exit 0'), /exact Core exit status/], + ['rejects dropped arguments', (command) => command.replace('"$@"', ''), /exact Core exit status|forward every argument/], + ]) { + await t.test(`${script}: ${scenario}`, () => { + const modified = structuredClone(installed); + modified.scripts[script] = mutate(modified.scripts[script]); + assert.notEqual(modified.scripts[script], installed.scripts[script]); + writeJson(packagePath, modified); + try { + const options = [...args, ...threshold]; + assert.throws(() => assertWrapper(wrapperRun(options), directRun(options), footer), expectedFailure); + } finally { + writeJson(packagePath, installed); + } + }); + } + } + } finally { + fs.rmSync(workRoot, { recursive: true, force: true }); + } +}); diff --git a/docs/generated-child-theme-contract.md b/docs/generated-child-theme-contract.md index 42c54ce..71f2cb6 100644 --- a/docs/generated-child-theme-contract.md +++ b/docs/generated-child-theme-contract.md @@ -52,6 +52,20 @@ npm run release:check -- --skip-smoke `npm run test:generated-theme` runs focused contract tests that generate a throwaway theme and then mutate it to prove each class of failure is detected. `npm run smoke:generation-parity` generates through both paths and compares them; it requires a PHP binary and skips with a warning when none is available. +To verify generated audit wrappers against an actual packed Core artifact, run: + +```sh +EMULSIFY_CORE_TARBALL=/absolute/path/emulsify-core-4.5.0.tgz npm run test:audit-wrappers +``` + +This opt-in test generates a temporary child theme and installs that package +without installer hooks. It compares both wrappers with the installed Core +executables, checks complete JSON stdout, stderr footers, exit codes 0/1/2, and +arguments containing spaces. Negative controls prove that stdout footers, +masked failures, and dropped arguments are detected. It requires Node 24 and +registry access for the packed package's dependencies; it leaves the source +theme and dependency ranges unchanged. + The full release check additionally exercises a real WordPress fixture: ```bash diff --git a/package.json b/package.json index 2bbf13f..2984546 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "pr:check": "node .github/scripts/pr-validation.cjs", "release:check": "node .github/scripts/release-check.cjs", "test:generated-theme": "node --test .github/scripts/generated-theme-contract.test.cjs", + "test:audit-wrappers": "node --test .github/scripts/generated-audit-wrappers.test.cjs", "smoke:acf-json": "php .github/scripts/acf-local-json-smoke.php", "smoke:asset-manifest": "php .github/scripts/asset-manifest-smoke.php", "smoke:attributes": "php .github/scripts/attribute-helper-smoke.php", diff --git a/whisk/docs/development.md b/whisk/docs/development.md index 5e4da05..8246bb1 100644 --- a/whisk/docs/development.md +++ b/whisk/docs/development.md @@ -91,6 +91,39 @@ npm run a11y `npm run test` succeeds when no tests have been added yet. The accessibility check builds Storybook first, so it takes longer than lint or unit tests. +For machine-readable project audits, silence npm's command banner and keep the +documentation footer on stderr: + +```bash +npm run audit --silent -- --json +npm run audit:twig-stories --silent -- --json +``` + +Both wrappers preserve Core's exit status and forward arguments unchanged. Use +`npm run audit --silent -- --json --fail-on warn` or +`npm run audit:twig-stories --silent -- --json --fail-on-found` to fail when +migration findings are present. Quote paths containing spaces, for example +`npm run audit --silent -- --root "../theme source" --json`. Existing generated +themes must copy the updated audit scripts from the current starter; updating +Core alone does not change a project's `package.json`. + +To update an existing wrapper, keep `"$@"`, the `status=$?` immediately after +Core runs, and `exit $status`. Add only `>&2` after the footer's `printf`: + +```diff +- printf "\nAudit docs: https://github.com/emulsify-ds/emulsify-core/blob/4.x/docs/migration-4x.md#storybook-migration\n"; exit $status ++ printf "\nAudit docs: https://github.com/emulsify-ds/emulsify-core/blob/4.x/docs/migration-4x.md#storybook-migration\n" >&2; exit $status +``` + +Apply the same redirection to `audit:twig-stories`, retaining its existing +`Migration docs:` footer. Alternatively, bypass the project wrappers and run +the installed Core executables directly without downloading another version: + +```bash +npx --no-install emulsify-audit --json +npx --no-install emulsify-audit-twig-stories --json +``` + ## Project ownership ### Component library diff --git a/whisk/package.json b/whisk/package.json index 787012a..0975ce9 100644 --- a/whisk/package.json +++ b/whisk/package.json @@ -18,8 +18,8 @@ "type": "module", "scripts": { "a11y": "npm run storybook-build && node config/emulsify-core/run-a11y.js", - "audit": "sh -c 'node_modules/@emulsify/core/scripts/audit.js \"$@\"; status=$?; printf \"\\nAudit docs: https://github.com/emulsify-ds/emulsify-core/blob/4.x/docs/migration-4x.md#storybook-migration\\n\"; exit $status' --", - "audit:twig-stories": "sh -c 'node_modules/@emulsify/core/scripts/audit-twig-stories.js \"$@\"; status=$?; printf \"\\nMigration docs: https://github.com/emulsify-ds/emulsify-core/blob/4.x/docs/storybook.md#legacy-twig-story-compatibility\\n\"; exit $status' --", + "audit": "sh -c 'node_modules/@emulsify/core/scripts/audit.js \"$@\"; status=$?; printf \"\\nAudit docs: https://github.com/emulsify-ds/emulsify-core/blob/4.x/docs/migration-4x.md#storybook-migration\\n\" >&2; exit $status' --", + "audit:twig-stories": "sh -c 'node_modules/@emulsify/core/scripts/audit-twig-stories.js \"$@\"; status=$?; printf \"\\nMigration docs: https://github.com/emulsify-ds/emulsify-core/blob/4.x/docs/storybook.md#legacy-twig-story-compatibility\\n\" >&2; exit $status' --", "build": "npm run ensure-dist && vite build --config node_modules/@emulsify/core/config/vite/vite.config.js", "coverage": "npm run test && open-cli .coverage/lcov-report/index.html", "develop": "npm run ensure-dist && concurrently --raw --no-shell npm:vite npm:storybook", From afb8591346c34a8dfc710c2c47e93c95fe2d0d18 Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Thu, 22 Oct 2026 15:49:46 -0500 Subject: [PATCH 002/100] fix(release): support prepared stable releases --- .github/scripts/release-check.cjs | 88 +++----- .github/scripts/release-version-contract.cjs | 100 +++++++++ .../scripts/release-version-contract.test.cjs | 166 ++++++++++++++ .github/scripts/semantic-release.cjs | 202 ++++++------------ .github/scripts/semantic-release.test.cjs | 118 ++++++++++ .github/workflows/theme-readiness.yml | 4 + README.md | 2 +- UPGRADE.md | 9 + docs/release-notes-next.md | 29 ++- docs/release-process.md | 63 +++++- release.config.js | 129 +---------- 11 files changed, 580 insertions(+), 330 deletions(-) create mode 100644 .github/scripts/release-version-contract.cjs create mode 100644 .github/scripts/release-version-contract.test.cjs create mode 100644 .github/scripts/semantic-release.test.cjs diff --git a/.github/scripts/release-check.cjs b/.github/scripts/release-check.cjs index 4cf3b52..ec8fcd1 100644 --- a/.github/scripts/release-check.cjs +++ b/.github/scripts/release-check.cjs @@ -7,8 +7,10 @@ const fs = require('fs'); const path = require('path'); const childProcess = require('child_process'); +const { validateReleaseVersionContract } = require('./release-version-contract.cjs'); const repoRoot = path.resolve(__dirname, '../..'); +const skipSmoke = process.argv.slice(2).includes('--skip-smoke'); const results = []; function addResult(status, name, detail) { @@ -46,8 +48,8 @@ function runStaticCheck(name, callback) { } function runCommandCheck(name, command, args) { - // Command checks are reserved for smoke paths that already know how to skip - // when local prerequisites such as WP-CLI or MySQL are absent. + // Focused contract tests always run. Runtime smoke commands can also report + // missing local prerequisites such as WP-CLI or MySQL as an explicit skip. const result = childProcess.spawnSync(command, args, { cwd: repoRoot, encoding: 'utf8', @@ -299,33 +301,12 @@ function runStaticChecks() { const license = readFile('LICENSE'); const issueTemplate = readFile('.github/ISSUE_TEMPLATE.md'); const pullRequestTemplate = readFile('.github/PULL_REQUEST_TEMPLATE.md'); - const releaseGuardRejectContext = { - branch: { name: 'main' }, - lastRelease: { version: '1.0.0' }, - nextRelease: { version: '1.1.0' }, - }; - const releaseGuardAcceptContext = { - branch: { name: 'main' }, - lastRelease: { version: '1.0.0' }, - nextRelease: { version: '2.0.0' }, - }; - const releaseGuardFutureContext = { - branch: { name: 'main' }, - lastRelease: { version: '2.0.0' }, - nextRelease: { version: '2.0.1' }, - }; - const releaseAnalyzerAlphaContext = { - branch: { name: 'main' }, - lastRelease: { version: '1.0.0-alpha.5' }, - }; - const releaseAnalyzerNoStableContext = { - branch: { name: 'main' }, - lastRelease: {}, - }; - runStaticCheck('Required files', () => { const requiredFiles = [ '.github/scripts/release-check.cjs', + '.github/scripts/release-version-contract.cjs', + '.github/scripts/release-version-contract.test.cjs', + '.github/scripts/semantic-release.test.cjs', '.github/scripts/docs-command-check.cjs', '.github/scripts/pr-validation.cjs', '.github/scripts/semantic-release.cjs', @@ -438,10 +419,17 @@ function runStaticChecks() { return `Found ${requiredFiles.length} required release files.`; }); + runStaticCheck('Release version contract', () => { + const { errors, packageVersion, latestStableTag, releasePrepared } = validateReleaseVersionContract({ repoRoot }); + ensure(errors.length === 0, errors.join('\n')); + return releasePrepared + ? `Prepared version ${packageVersion} follows latest stable tag ${latestStableTag}.` + : `Package version ${packageVersion} matches latest stable tag ${latestStableTag}; no release is prepared.`; + }); + runStaticCheck('Root release metadata', () => { ensure(rootPackage.name === 'emulsify-wordpress', 'package.json name should be emulsify-wordpress.'); ensure(semver(rootPackage.version), 'package.json version must be a valid semver string.'); - ensure(rootPackage.version === releaseConfig.expectedStableRelease, `package.json version should prepare the ${releaseConfig.expectedStableRelease} release.`); ensure(rootPackage.description, 'package.json description is required.'); ensureParentThemeLanguage('package.json description', rootPackage.description); ensureNoTitleCaseBuildPhrase('package.json description', rootPackage.description); @@ -1208,7 +1196,6 @@ function runStaticChecks() { const analyzerOptions = getReleasePluginOptions(releaseConfig, '@semantic-release/commit-analyzer'); const notesOptions = getReleasePluginOptions(releaseConfig, '@semantic-release/release-notes-generator'); const githubOptions = getReleasePluginOptions(releaseConfig, '@semantic-release/github'); - const releaseAnalyzer = releaseConfig.plugins.find((plugin) => plugin && typeof plugin.analyzeCommits === 'function'); const releaseGuard = releaseConfig.plugins.find((plugin) => plugin && typeof plugin.verifyRelease === 'function'); const readinessJobStart = semanticReleaseWorkflow.indexOf(' release-readiness:'); const releaseJobStart = semanticReleaseWorkflow.indexOf(' semantic-release:'); @@ -1219,20 +1206,14 @@ function runStaticChecks() { const releaseAsset = Array.isArray(githubOptions.assets) ? githubOptions.assets.find((asset) => asset.path === 'dist-artifact/emulsify.zip') : null; - ensure(releaseConfig.expectedStableRelease === '2.0.0', 'release.config.js should declare 2.0.0 as the expected stable release.'); - ensure(releaseConfig.initialStableBaseline === '1.0.0', 'release.config.js should declare the temporary 1.0.0 stable baseline.'); ensure(releaseConfig.tagFormat === '${version}', 'release.config.js should emit non-prefixed semver tags.'); ensure(releaseConfig.repositoryUrl === 'git@github.com:emulsify-ds/emulsify-wordpress.git', 'release.config.js should publish against emulsify-wordpress.'); ensure(Array.isArray(releaseConfig.branches), 'release.config.js branches must be an array.'); ensure(releaseConfig.branches.length === 1 && releaseConfig.branches[0] === 'main', 'release.config.js should publish only from main.'); - ensure(releaseAnalyzer, 'release.config.js should force the first stable release to a major release type.'); - ensure(releaseGuard, 'release.config.js should guard the first stable release version.'); + ensure(!releaseConfig.plugins.some((plugin) => ['@semantic-release/npm', '@semantic-release/git'].includes(Array.isArray(plugin) ? plugin[0] : plugin)), 'release.config.js must not rewrite committed versions through @semantic-release/npm or @semantic-release/git.'); + ensure(releaseGuard, 'release.config.js should require the computed release version to match the prepared package version.'); ensure(rootPackage.scripts.publish.startsWith('node .github/scripts/semantic-release.cjs '), 'The publish script should prepare semantic-release through the guarded runner.'); ensure(rootPackage.scripts['publish-test'].startsWith('node .github/scripts/semantic-release.cjs '), 'The publish-test script should use the same guarded semantic-release runner.'); - ensure(semanticReleaseRunner.includes("git(['tag', initialStableBaseline, sourceSha])"), 'The semantic-release runner should expose the latest prerelease commit as a temporary stable baseline.'); - ensure(semanticReleaseRunner.includes('finally') && semanticReleaseRunner.includes('removeStableBaseline(temporaryBaseline)'), 'The semantic-release runner should always clean up its temporary baseline tag.'); - ensure(releaseGuard.verifyRelease.toString().includes('removeSyntheticBaseline'), 'The stable release guard should remove the temporary baseline before semantic-release pushes tags.'); - ensure(typeof notesOptions.writerOpts?.finalizeContext === 'function', 'Release notes should suppress the inaugural compare link to the temporary baseline tag.'); ensure(releaseAsset, 'release.config.js should attach dist-artifact/emulsify.zip to GitHub releases.'); ensure(releaseAsset.label === 'Emulsify WordPress theme (with dependencies)', 'release.config.js should give the installable ZIP a clear release label.'); ensureBreakingParser('@semantic-release/commit-analyzer', analyzerOptions.parserOpts); @@ -1242,6 +1223,7 @@ function runStaticChecks() { ensure(semanticReleaseWorkflow.includes('contents: write'), 'semantic-release.yml should grant GitHub release permissions explicitly.'); ensure(semanticReleaseWorkflow.includes('id: semantic'), 'semantic-release.yml should expose the semantic-release step as steps.semantic.'); ensure(semanticReleaseWorkflow.includes('npm run release:check'), 'semantic-release.yml should run local release readiness checks.'); + ensure(readinessJob.includes('fetch-depth: 0') && releaseJob.includes('fetch-depth: 0'), 'Release readiness and publishing should fetch full history and tags for the release version contract.'); ensure(semanticReleaseWorkflow.includes('wp-cli'), 'semantic-release.yml should install WP-CLI for the WordPress smoke fixture.'); ensure(semanticReleaseWorkflow.includes('mysql:'), 'semantic-release.yml should provide a MySQL service for the WordPress smoke fixture.'); ensure(semanticReleaseWorkflow.includes('WP_SMOKE_DB_HOST'), 'semantic-release.yml should pass WordPress smoke database settings.'); @@ -1252,21 +1234,8 @@ function runStaticChecks() { ensure(releaseJob.includes('tools: composer:v2'), 'semantic-release.yml release job should install Composer.'); ensure(buildStep >= 0, 'semantic-release.yml release job should build the installable theme archive.'); ensure(publishStep >= 0 && buildStep < publishStep, 'semantic-release.yml should build the installable theme archive before publishing.'); - try { - releaseGuard.verifyRelease({}, releaseGuardRejectContext); - throw new Error('release.config.js release guard should reject pre-2.0.0 releases.'); - } - catch (error) { - const expectedMessage = error.message.includes('Expected semantic-release to prepare 2.0.0'); - ensure(expectedMessage, 'release.config.js release guard should explain the expected 2.0.0 release.'); - } - releaseGuard.verifyRelease({}, releaseGuardAcceptContext); - releaseGuard.verifyRelease({}, releaseGuardFutureContext); - ensure(releaseAnalyzer.analyzeCommits({}, releaseAnalyzerAlphaContext) === 'major', 'release.config.js should force a major release from the latest alpha tag.'); - ensure(releaseAnalyzer.analyzeCommits({}, releaseAnalyzerNoStableContext) === 'major', 'release.config.js should force a major release when semantic-release finds no previous stable release.'); - ensure(releaseAnalyzer.analyzeCommits({}, releaseGuardRejectContext) === 'major', 'release.config.js should force a major release before 2.0.0 exists.'); - ensure(releaseAnalyzer.analyzeCommits({}, releaseGuardFutureContext) === null, 'release.config.js should use normal commit analysis after 2.0.0 exists.'); - return 'Semantic release is configured for non-prefixed tags, main-only publishing, a forced 2.0.0 stable release, and the installable ZIP asset.'; + ensure(semanticReleaseRunner.includes('Refusing to publish local-only stable tag(s)'), 'The guarded runner should refuse to push unpublished local stable tags.'); + return 'Semantic release requires a prepared package version, non-prefixed tags, main-only publishing, and the tested installable ZIP asset.'; }); runStaticCheck('Installable release artifact', () => { @@ -1321,6 +1290,7 @@ function runStaticChecks() { ensure(themeReadinessWorkflow.includes('npm run lint:php'), 'theme-readiness.yml should run PHP lint.'); ensure(themeReadinessWorkflow.includes('npm run pr:check'), 'theme-readiness.yml should delegate project smoke checks to npm run pr:check.'); ensure(themeReadinessWorkflow.includes('npm run release:check'), 'theme-readiness.yml should run release readiness checks.'); + ensure(practicalJob.includes('fetch-depth: 0') && wordpressFixtureJob.includes('fetch-depth: 0'), 'Readiness jobs running the release version contract should fetch full history and tags.'); ensure(practicalJob.includes("- name: Build installable theme archive\n if: matrix.php-version == '8.3'\n run: npm run build:dist"), 'The PHP 8.3 practical lane should build the client-testable release archive.'); ensure(practicalJob.includes("- name: Upload installable theme archive\n if: matrix.php-version == '8.3'\n uses: actions/upload-artifact@v7"), 'The PHP 8.3 practical lane should use direct-file artifact upload.'); ensure(practicalJob.includes('path: dist-artifact/emulsify.zip') && practicalJob.includes('archive: false') && practicalJob.includes('overwrite: true'), 'Theme readiness should upload the installable ZIP directly and support workflow reruns.'); @@ -1396,7 +1366,7 @@ function runStaticChecks() { 'docs/post-2x-optimization-roadmap.md', ]; - ensure(readme.includes('Emulsify WordPress 2.0.0 is a Timber-first WordPress parent theme'), 'README.md should describe the 2.0.0 Timber-first parent theme.'); + ensure(readme.includes('Emulsify WordPress is a Timber-first WordPress parent theme'), 'README.md should describe the Timber-first parent theme.'); ensure(readme.includes('Emulsify WordPress is licensed under GPL-2.0-only'), 'README.md should document the GPL-2.0-only license.'); ensure(readme.includes('[LICENSE](LICENSE)'), 'README.md should link to the repository license file.'); ensure(readme.includes('## Requirements'), 'README.md should keep requirements visible.'); @@ -1534,7 +1504,7 @@ function runStaticChecks() { ensure(docs.cli.includes('Force replacement safety') && docs.cli.includes('Emulsify-generated child theme markers'), 'WP-CLI doc should document force replacement safety.'); ensure(docs.cli.includes('Upgrade and support diagnostics') && docs.cli.includes('generatedFromVersion'), 'WP-CLI doc should document generated child theme lineage diagnostics.'); ensure(docs.cli.includes('Ignored dependency, cache, Storybook, and Vite output directories are not copied'), 'WP-CLI doc should explain that generated themes do not inherit build output.'); - ensure(docs.release.includes('release-2.x') && docs.release.includes('2.0.0'), 'Release process doc should document the release-2.x target release.'); + ensure(docs.release.includes('## Release PR procedure') && docs.release.includes('prepared version'), 'Release process doc should explain preparing a version in a release PR.'); ensure(docs.release.includes('Packagist lists `emulsify-ds/emulsify-wordpress-theme` as maintained'), 'Release process doc should require the published Composer package to be un-abandoned.'); ensure(docs.release.includes('WordPress Theme Readiness workflow'), 'Release process doc should document the theme readiness workflow.'); ensure(docs.index.includes('## Continuous integration') && docs.index.includes('dedicated PHPCS/PHPStan job') && docs.index.includes('Pull requests targeting `main` or `release-2.x` additionally run'), 'Docs index should summarize fast and release-targeted pull-request readiness coverage.'); @@ -1611,7 +1581,7 @@ function runStaticChecks() { ensure(issueTemplate.includes('emulsify-wordpress/releases'), 'Issue template should link to WordPress theme releases.'); ensure(pullRequestTemplate.includes('emulsify-wordpress/issues/1'), 'Pull request template should link to WordPress theme issues.'); ensure(!/emulsify-drupal/.test(`${issueTemplate}\n${pullRequestTemplate}`), 'GitHub templates should not link to the Drupal repository.'); - return 'README, docs, and GitHub templates match the WordPress 2.0.0 release story.'; + return 'README, docs, and GitHub templates match the WordPress parent theme release process.'; }); runStaticCheck('License metadata', () => { @@ -1654,7 +1624,15 @@ function runStaticChecks() { return 'No .DS_Store files were found outside .git.'; }); - runCommandCheck('WordPress fixture smoke', process.execPath, ['.github/scripts/wordpress-fixture-smoke.cjs']); + runCommandCheck('Release version contract tests', process.execPath, ['--test', '.github/scripts/release-version-contract.test.cjs']); + runCommandCheck('Semantic release guard tests', process.execPath, ['--test', '.github/scripts/semantic-release.test.cjs']); + + if (skipSmoke) { + addResult('SKIP', 'WordPress fixture smoke', 'Skipped by --skip-smoke.'); + } + else { + runCommandCheck('WordPress fixture smoke', process.execPath, ['.github/scripts/wordpress-fixture-smoke.cjs']); + } } function printSummary() { diff --git a/.github/scripts/release-version-contract.cjs b/.github/scripts/release-version-contract.cjs new file mode 100644 index 0000000..b70a33f --- /dev/null +++ b/.github/scripts/release-version-contract.cjs @@ -0,0 +1,100 @@ +#!/usr/bin/env node + +const fs = require('node:fs'); +const path = require('node:path'); +const { execFileSync } = require('node:child_process'); + +function stableVersionParts(version) { + if (typeof version !== 'string') return null; + const match = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.exec(version); + if (!match || match[0] !== version) return null; + const parts = match.slice(1).map(Number); + return parts.every(Number.isSafeInteger) ? parts : null; +} + +function compareVersions(left, right) { + const leftParts = stableVersionParts(left); + const rightParts = stableVersionParts(right); + for (let index = 0; index < 3; index += 1) { + if (leftParts[index] !== rightParts[index]) return rightParts[index] - leftParts[index]; + } + return 0; +} + +function validateReleaseVersions({ tags = [], latestStableTag, packageVersion, documents = {} }) { + const errors = []; + const stableTags = tags.filter((tag) => stableVersionParts(tag)).sort(compareVersions); + latestStableTag ??= stableTags[0]; + const result = { errors, packageVersion, latestStableTag, stableTags, releasePrepared: false }; + + if (!stableVersionParts(latestStableTag)) { + errors.push('No stable release tag is available; fetch the full Git history and tags (fetch-depth: 0).'); + return result; + } + + if (!stableVersionParts(packageVersion)) { + errors.push('package.json version must use a canonical stable x.y.z version.'); + } else { + const [major, minor, patch] = stableVersionParts(latestStableTag); + const nextVersions = [`${major}.${minor}.${patch + 1}`, `${major}.${minor + 1}.0`, `${major + 1}.0.0`]; + if (packageVersion !== latestStableTag && (tags.includes(packageVersion) || !nextVersions.includes(packageVersion))) { + errors.push(`package.json version must equal the latest stable tag ${latestStableTag} or an untagged next patch, minor, or major version (${nextVersions.join(', ')}).`); + } + } + + const releasedVersions = new Set(tags); + const draft = documents['docs/release-notes-next.md'] || ''; + for (const line of draft.split(/\r?\n/)) { + // Draft headings and release titles claim a version; historical prose and + // compatibility requirements elsewhere in the draft do not. + if (!/^\s*(?:#{1,6}\s|Title\s*:)/i.test(line)) continue; + for (const match of line.matchAll(/\bv?(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?)\b/g)) { + if (releasedVersions.has(match[1])) { + errors.push(`The next-release draft names already-tagged release ${match[1]}; keep the draft unreleased.`); + } + } + } + + for (const [file, text] of Object.entries(documents)) { + for (const match of text.matchAll(/(?:latest|current)\s+(?:(?:published|stable)\s+)?(?:release|tag|version)\s*:\s*[`*]*v?(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?)\b/gi)) { + if (match[1] !== latestStableTag) { + errors.push(`${file} names a stale current release (${match[1]}); use the latest stable tag ${latestStableTag} or remove the claim.`); + } + } + } + + result.releasePrepared = errors.length === 0 && packageVersion !== latestStableTag; + return result; +} + +function validateReleaseVersionContract({ repoRoot = path.resolve(__dirname, '../..') } = {}) { + try { + const git = (...args) => execFileSync('git', args, { cwd: repoRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim(); + if (git('rev-parse', '--is-shallow-repository') !== 'false') { + return { errors: ['Release version checks require full Git history and tags (fetch-depth: 0).'], releasePrepared: false }; + } + const tags = git('tag', '--list').split(/\r?\n/).filter(Boolean); + if (!tags.some((tag) => stableVersionParts(tag))) return validateReleaseVersions({ tags }); + const read = (file) => fs.readFileSync(path.join(repoRoot, file), 'utf8'); + const documents = Object.fromEntries([ + 'README.md', + 'docs/release-notes-next.md', + 'docs/release-process.md', + ].map((file) => [file, read(file)])); + return validateReleaseVersions({ tags, packageVersion: JSON.parse(read('package.json')).version, documents }); + } catch (error) { + return { errors: [`Unable to validate the release version contract: ${error.message}`], releasePrepared: false }; + } +} + +if (require.main === module) { + const result = validateReleaseVersionContract(); + if (result.errors.length > 0) { + for (const error of result.errors) console.error(`FAIL ${error}`); + process.exitCode = 1; + } else { + console.log(`PASS Release version policy and unreleased draft (latest tag: ${result.latestStableTag}; package: ${result.packageVersion}).`); + } +} + +module.exports = { validateReleaseVersionContract, validateReleaseVersions }; diff --git a/.github/scripts/release-version-contract.test.cjs b/.github/scripts/release-version-contract.test.cjs new file mode 100644 index 0000000..e8dee24 --- /dev/null +++ b/.github/scripts/release-version-contract.test.cjs @@ -0,0 +1,166 @@ +const assert = require('node:assert/strict'); +const { execFileSync } = require('node:child_process'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { pathToFileURL } = require('node:url'); +const test = require('node:test'); +const { validateReleaseVersionContract, validateReleaseVersions } = require('./release-version-contract.cjs'); + +function fixture(overrides = {}) { + return { + tags: ['1.0.0-alpha.4', '2.0.0', '1.9.9'], + packageVersion: '2.0.0', + documents: { + 'README.md': 'Emulsify WordPress is the parent theme. Current release: `2.0.0`.', + 'docs/release-notes-next.md': '# 2.1.0 Release Notes Draft\n\nRequires Core 4.3.2. Latest published tag: 2.0.0.', + 'docs/release-process.md': 'Review commits since the latest tag.', + }, + ...overrides, + }; +} + +function validate(overrides) { + return validateReleaseVersions(fixture(overrides)); +} + +test('accepts the latest tag and exact untagged next patch, minor, or major', () => { + for (const packageVersion of ['2.0.0', '2.0.1', '2.1.0', '3.0.0']) { + const result = validate({ packageVersion }); + assert.deepEqual(result.errors, []); + assert.equal(result.latestStableTag, '2.0.0'); + assert.equal(result.releasePrepared, packageVersion !== '2.0.0'); + } +}); + +test('rejects old versions and skipped increments', () => { + for (const packageVersion of ['1.9.9', '1.9.8', '2.0.2', '2.1.1', '2.2.0', '3.0.1', '4.0.0']) { + const result = validate({ packageVersion }); + assert.match(result.errors.join('\n'), /untagged next patch, minor, or major/); + assert.equal(result.releasePrepared, false); + } +}); + +test('rejects a proposed next version that is already tagged', () => { + for (const packageVersion of ['2.0.1', '2.1.0', '3.0.0']) { + assert.match(validate({ + tags: ['2.0.0', packageVersion], + latestStableTag: '2.0.0', + packageVersion, + }).errors.join('\n'), /untagged next patch, minor, or major/); + } +}); + +test('rejects prereleases and noncanonical package versions', () => { + for (const packageVersion of [ + undefined, null, 2.1, '', '2', '2.1', 'v2.1.0', '2.1.0-beta.1', + '2.1.0+build.1', '2.1.0.1', '02.1.0', '2.01.0', '2.1.00', ' 2.1.0', '2.1.0\n', + '9007199254740992.0.0', + ]) { + assert.match(validate({ packageVersion }).errors.join('\n'), /canonical stable x.y.z/); + } +}); + +test('finds the latest stable unprefixed tag using numeric version order', () => { + const result = validate({ + tags: ['2.9.0', 'v10.0.0', '2.10.0-beta.1', '2.10.0', '02.20.0'], + packageVersion: '2.10.0', + documents: {}, + }); + assert.deepEqual(result.errors, []); + assert.equal(result.latestStableTag, '2.10.0'); + assert.deepEqual(result.stableTags, ['2.10.0', '2.9.0']); +}); + +test('fails clearly when full tags contain no stable release', () => { + for (const tags of [[], ['v2.0.0', '2.1.0-beta.1']]) { + assert.match(validate({ tags }).errors.join('\n'), /No stable release tag.*full Git history and tags/); + } +}); + +test('rejects already-shipped draft headings and release titles, including prereleases', () => { + for (const draft of [ + '# 2.0.0 Release Notes Draft', + '## 1.9.9', + '# Unreleased\nTitle: `emulsify 2.0.0`', + '# Unreleased\nTitle: `v1.9.9`', + '# 1.0.0-alpha.4 Release Notes', + ]) { + assert.match(validate({ documents: { 'docs/release-notes-next.md': draft } }).errors.join('\n'), /already-tagged release/); + } +}); + +test('accepts historical upgrade headings, compatibility text, and current published-tag labels', () => { + assert.deepEqual(validate({ documents: { + 'UPGRADE.md': '## Upgrading from 1.9.9 to 2.0.0', + 'docs/release-notes-next.md': '# Unreleased\nRequires Core 4.3.2. Follow the upgrade notes from 2.0.0.\nLatest published tag: `2.0.0`.', + } }).errors, []); +}); + +test('rejects stale latest/current release claims in each release document', () => { + for (const file of ['README.md', 'docs/release-notes-next.md', 'docs/release-process.md']) { + for (const claim of [ + 'Latest release: 1.9.9', 'Current release: **1.9.9**', 'Latest stable tag: 1.9.9', + 'Latest published tag: `1.9.9`', 'current version: v1.9.9', + 'Latest release: 2.0.0-beta.1', + ]) { + assert.match(validate({ documents: { [file]: claim } }).errors.join('\n'), /stale current release/); + } + } +}); + +test('keeps a prepared package valid after its tag is published', () => { + assert.deepEqual(validate({ packageVersion: '2.1.0', documents: {} }).errors, []); + const result = validate({ tags: ['2.1.0', '2.0.0'], packageVersion: '2.1.0', documents: {} }); + assert.deepEqual(result.errors, []); + assert.equal(result.releasePrepared, false); + assert.match(validate({ tags: ['2.1.0', '2.0.0'], packageVersion: '2.0.0', documents: {} }).errors.join('\n'), /latest stable tag 2.1.0/); +}); + +function git(repoRoot, ...args) { + return execFileSync('git', args, { cwd: repoRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim(); +} + +function repositoryFixture(t) { + const workRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'emulsify-release-version-')); + t.after(() => fs.rmSync(workRoot, { recursive: true, force: true })); + const repoRoot = path.join(workRoot, 'repository'); + fs.mkdirSync(path.join(repoRoot, 'docs'), { recursive: true }); + const { documents, packageVersion } = fixture(); + for (const [file, contents] of Object.entries(documents)) fs.writeFileSync(path.join(repoRoot, file), contents); + fs.writeFileSync(path.join(repoRoot, 'package.json'), JSON.stringify({ version: packageVersion })); + git(repoRoot, 'init', '--quiet'); + git(repoRoot, 'add', '.'); + git(repoRoot, '-c', 'user.name=Release Test', '-c', 'user.email=release@example.test', 'commit', '--quiet', '-m', 'test: create release fixture'); + return { workRoot, repoRoot }; +} + +test('reads package metadata, real Git tags, and release documents from a repository', (t) => { + const { repoRoot } = repositoryFixture(t); + git(repoRoot, 'tag', '2.0.0'); + let result = validateReleaseVersionContract({ repoRoot }); + assert.deepEqual(result.errors, []); + assert.equal(result.latestStableTag, '2.0.0'); + assert.equal(result.packageVersion, '2.0.0'); + assert.equal(result.releasePrepared, false); + + fs.writeFileSync(path.join(repoRoot, 'README.md'), 'Latest release: 1.9.9'); + result = validateReleaseVersionContract({ repoRoot }); + assert.match(result.errors.join('\n'), /README.md names a stale current release/); +}); + +test('rejects a real Git repository with no stable tag', (t) => { + const { repoRoot } = repositoryFixture(t); + git(repoRoot, 'tag', '2.0.0-beta.1'); + fs.rmSync(path.join(repoRoot, 'docs'), { recursive: true }); + assert.match(validateReleaseVersionContract({ repoRoot }).errors.join('\n'), /No stable release tag/); +}); + +test('rejects a shallow clone even when its stable tag is available', (t) => { + const { repoRoot, workRoot } = repositoryFixture(t); + git(repoRoot, 'tag', '2.0.0'); + const shallowRoot = path.join(workRoot, 'shallow'); + git(workRoot, 'clone', '--quiet', '--depth=1', pathToFileURL(repoRoot).href, shallowRoot); + assert.equal(git(shallowRoot, 'rev-parse', '--is-shallow-repository'), 'true'); + assert.match(validateReleaseVersionContract({ repoRoot: shallowRoot }).errors.join('\n'), /require full Git history and tags \(fetch-depth: 0\)/); +}); diff --git a/.github/scripts/semantic-release.cjs b/.github/scripts/semantic-release.cjs index ac3926d..545d2cc 100644 --- a/.github/scripts/semantic-release.cjs +++ b/.github/scripts/semantic-release.cjs @@ -1,162 +1,94 @@ #!/usr/bin/env node -const { execFileSync, spawnSync } = require('node:child_process'); +const { execFileSync } = require('node:child_process'); const path = require('node:path'); +const { parseArgs } = require('node:util'); +const { validateReleaseVersionContract } = require('./release-version-contract.cjs'); -const repoRoot = path.resolve(__dirname, '..', '..'); -const { - expectedStableRelease, - initialStableBaseline, -} = require(path.join(repoRoot, 'release.config.js')); -const syntheticBaselineEnvironment = 'EMULSIFY_SYNTHETIC_STABLE_BASELINE'; -const syntheticBaselineShaEnvironment = 'EMULSIFY_SYNTHETIC_STABLE_BASELINE_SHA'; -const syntheticBaselineSourceEnvironment = 'EMULSIFY_SYNTHETIC_STABLE_BASELINE_SOURCE'; - -function git(args, options = {}) { - const output = execFileSync('git', args, { - cwd: repoRoot, - encoding: 'utf8', - ...options, - }); - - return typeof output === 'string' ? output.trim() : ''; -} - -function mergedTags() { - const output = git(['tag', '--merged', 'HEAD', '--sort=-version:refname']); - - return output === '' ? [] : output.split(/\r?\n/); +function git(repoRoot, args) { + return execFileSync('git', args, { cwd: repoRoot, encoding: 'utf8' }).trim(); } -function publishedTags() { - const output = git(['ls-remote', '--tags', 'origin']); - const tags = new Set(); +function assertPublishedStableTags(repoRoot) { + const stableTags = git(repoRoot, ['tag', '--list']) + .split(/\r?\n/).filter((tag) => /^\d+\.\d+\.\d+$/.test(tag)); + const remoteTags = new Set(git(repoRoot, ['ls-remote', '--tags', 'origin']) + .split(/\r?\n/).map((line) => (line.split(/\s+/)[1] || '') + .replace(/^refs\/tags\//, '').replace(/\^\{\}$/, ''))); + const localOnlyTags = stableTags.filter((tag) => !remoteTags.has(tag)); - for (const line of output.split(/\r?\n/)) { - const ref = line.trim().split(/\s+/)[1] || ''; - if (ref.startsWith('refs/tags/')) { - tags.add(ref.slice('refs/tags/'.length).replace(/\^\{\}$/, '')); - } + if (localOnlyTags.length > 0) { + throw new Error( + `Refusing to publish local-only stable tag(s): ${localOnlyTags.join(', ')}. Remove them or publish them intentionally first.`, + ); } - - return tags; } -function tagCommit(tag) { - try { - return git(['rev-parse', '--verify', `${tag}^{commit}`]); - } - catch { - return null; - } +function releaseOptions(args) { + const { values } = parseArgs({ + args, + allowNegative: true, + options: { + 'tag-format': { type: 'string', short: 't' }, + 'dry-run': { type: 'boolean', short: 'd' }, + debug: { type: 'boolean' }, + ci: { type: 'boolean' }, + }, + }); + return { + ...(values['tag-format'] !== undefined && { tagFormat: values['tag-format'] }), + ...(values['dry-run'] !== undefined && { dryRun: values['dry-run'] }), + ...(values.ci !== undefined && { ci: values.ci }), + ...(values.debug !== undefined && { debug: values.debug }), + }; } -function prepareStableBaseline() { - const branch = process.env.GITHUB_REF_NAME || git(['branch', '--show-current']); - - if (branch !== 'main') { - return null; - } - - const tags = mergedTags(); - const stableTags = tags.filter((tag) => /^\d+\.\d+\.\d+$/.test(tag)); - - if (stableTags.length > 0) { - const remoteTags = publishedTags(); - const localOnlyTags = stableTags.filter((tag) => !remoteTags.has(tag)); - - if (localOnlyTags.length > 0) { - throw new Error( - `Refusing to publish local-only stable tag(s): ${localOnlyTags.join(', ')}. Remove them or publish them intentionally first.` - ); - } - - return null; - } - - const prereleaseTag = tags.find((tag) => tag.startsWith(`${initialStableBaseline}-`)); - - if (!prereleaseTag) { - throw new Error( - `Cannot prepare ${expectedStableRelease}: no stable tag or merged ${initialStableBaseline} prerelease tag was found.` - ); +async function runSemanticRelease(options, context) { + if (options.debug) { + (await import('debug')).default.enable('semantic-release:*'); } + return (await import('semantic-release')).default(options, context); +} - const sourceSha = tagCommit(prereleaseTag); - if (!sourceSha) { - throw new Error(`Cannot resolve ${prereleaseTag} to a commit for the temporary stable baseline.`); +async function main({ + repoRoot = path.resolve(__dirname, '../..'), + args = process.argv.slice(2), + runRelease = runSemanticRelease, +} = {}) { + const options = releaseOptions(args); + const contract = validateReleaseVersionContract({ repoRoot }); + if (contract.errors.length > 0) { + throw new Error(contract.errors.join('\n')); } - git(['tag', initialStableBaseline, sourceSha]); - return { - sha: sourceSha, - source: prereleaseTag, - tag: initialStableBaseline, - }; -} - -function removeStableBaseline(baseline) { - if (!baseline) { - return; + // Check before skipping too: semantic-release must never push an unpublished + // stable tag from a developer's checkout. + assertPublishedStableTags(repoRoot); + if (!contract.releasePrepared) { + console.log(`No release prepared: package.json matches latest stable tag ${contract.latestStableTag}. Skipping publishing.`); + return 0; } - const existingSha = tagCommit(baseline.tag); - if (!existingSha) { - return; + // A release PR can verify its version before merging to main. This override + // is only for an explicit local dry run, which cannot tag or publish. + if (options.dryRun && options.ci === false) { + options.branches = [git(repoRoot, ['branch', '--show-current'])]; } - if (existingSha !== baseline.sha) { + const result = await runRelease(options, { cwd: repoRoot }); + if (result?.nextRelease?.version !== contract.packageVersion) { throw new Error( - `Refusing to delete ${baseline.tag}: it no longer points to the temporary baseline commit ${baseline.sha}.` + `Prepared package.json version ${contract.packageVersion}, but semantic-release ${result?.nextRelease ? `computed ${result.nextRelease.version}` : 'did not compute a release'}. Run on main with releasable Conventional Commits matching the prepared version, then rerun npm run release:check.`, ); } - - git(['tag', '--delete', baseline.tag], { stdio: 'ignore' }); + return 0; } -function main() { - let temporaryBaseline = null; - - try { - temporaryBaseline = prepareStableBaseline(); - - const cli = require.resolve('semantic-release/bin/semantic-release.js'); - const result = spawnSync(process.execPath, [cli, ...process.argv.slice(2)], { - cwd: repoRoot, - env: { - ...process.env, - [syntheticBaselineEnvironment]: temporaryBaseline?.tag || '', - [syntheticBaselineShaEnvironment]: temporaryBaseline?.sha || '', - [syntheticBaselineSourceEnvironment]: temporaryBaseline?.source || '', - }, - stdio: 'inherit', - }); - - if (result.error) { - throw result.error; - } - - if ( - result.status === 0 - && temporaryBaseline - && tagCommit(temporaryBaseline.tag) - ) { - throw new Error( - `Semantic-release completed without removing the temporary ${temporaryBaseline.tag} baseline tag.` - ); - } - - return Number.isInteger(result.status) ? result.status : 1; - } - finally { - removeStableBaseline(temporaryBaseline); - } +if (require.main === module) { + main().then((status) => { process.exitCode = status; }).catch((error) => { + console.error(`Semantic release runner failed: ${error.message}`); + process.exitCode = 1; + }); } -try { - process.exitCode = main(); -} -catch (error) { - console.error(`Semantic release runner failed: ${error.message}`); - process.exitCode = 1; -} +module.exports = { assertPublishedStableTags, main, releaseOptions }; diff --git a/.github/scripts/semantic-release.test.cjs b/.github/scripts/semantic-release.test.cjs new file mode 100644 index 0000000..7e41ef4 --- /dev/null +++ b/.github/scripts/semantic-release.test.cjs @@ -0,0 +1,118 @@ +const assert = require('node:assert/strict'); +const { execFileSync } = require('node:child_process'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { test } = require('node:test'); +const { assertPublishedStableTags, main, releaseOptions } = require('./semantic-release.cjs'); +const releaseConfig = require('../../release.config.js'); +const guard = releaseConfig.plugins.find((plugin) => typeof plugin.verifyRelease === 'function'); + +function fixture(t, version = '2.1.0') { + const workRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'emulsify-publish-contract-')); + t.after(() => fs.rmSync(workRoot, { recursive: true, force: true })); + const repoRoot = path.join(workRoot, 'checkout'); + const remote = path.join(workRoot, 'origin.git'); + fs.mkdirSync(repoRoot); + const git = (...args) => execFileSync('git', args, { cwd: repoRoot, encoding: 'utf8', stdio: 'pipe' }).trim(); + git('init', '--bare', remote); + git('init', '-b', 'main'); + git('-c', 'user.name=Contract Test', '-c', 'user.email=contract@example.invalid', 'commit', '--allow-empty', '-m', 'chore: initial release'); + git('-c', 'user.name=Contract Test', '-c', 'user.email=contract@example.invalid', 'tag', '-a', '2.0.0', '-m', '2.0.0'); + git('remote', 'add', 'origin', remote); + git('push', 'origin', 'main', '--tags'); + fs.mkdirSync(path.join(repoRoot, 'docs')); + for (const file of ['README.md', 'docs/release-process.md', 'docs/release-notes-next.md']) { + fs.writeFileSync(path.join(repoRoot, file), ''); + } + fs.writeFileSync(path.join(repoRoot, 'package.json'), JSON.stringify({ name: 'release-contract', version })); + return { repoRoot, remote, git }; +} + +test('publish guard accepts the exact prepared version on every subsequent release', (t) => { + const { repoRoot } = fixture(t); + guard.verifyRelease({}, { cwd: repoRoot, nextRelease: { version: '2.1.0' } }); + assert.throws(() => guard.verifyRelease({}, { cwd: repoRoot, nextRelease: { version: '2.0.1' } }), /Prepared package.json version 2\.1\.0.*computed 2\.0\.1.*release PR/); + fs.writeFileSync(path.join(repoRoot, 'package.json'), JSON.stringify({ version: '3.0.0' })); + assert.throws(() => guard.verifyRelease({}, { cwd: repoRoot, lastRelease: { version: '2.1.0' }, nextRelease: { version: '2.2.0' } }), /Prepared package.json version 3\.0\.0.*computed 2\.2\.0/); +}); + +test('skips publishing successfully when package.json equals the latest tag', async (t) => { + const { repoRoot } = fixture(t, '2.0.0'); + const status = await main({ repoRoot, args: ['--dry-run', '--debug', '--no-ci'], runRelease: () => assert.fail('An unprepared release must not invoke semantic-release.') }); + assert.equal(status, 0); +}); + +test('prepared release accepts a matching semantic-release result and forwards documented flags', async (t) => { + const { repoRoot } = fixture(t); + const status = await main({ repoRoot, args: ['--tag-format', '${version}', '--dry-run', '--debug', '--no-ci'], runRelease: async (options, context) => { + assert.equal(context.cwd, repoRoot); + assert.equal(options.tagFormat, '${version}'); + assert.equal(options.dryRun, true); + assert.equal(options.debug, true); + assert.equal(options.ci, false); + guard.verifyRelease({}, { ...context, nextRelease: { version: '2.1.0' } }); + return { nextRelease: { version: '2.1.0' } }; + } }); + assert.equal(status, 0); +}); + +test('prepared release rejects mismatched or absent semantic-release results', async (t) => { + const { repoRoot } = fixture(t); + for (const result of [false, { nextRelease: { version: '2.0.1' } }]) { + await assert.rejects(main({ repoRoot, args: [], runRelease: async () => result }), /Prepared package.json version 2\.1\.0.*(?:computed 2\.0\.1|did not compute a release).*Conventional Commits/); + } +}); + +test('refuses local-only stable tags before any publishing or skip', async (t) => { + const { repoRoot, git } = fixture(t, '2.0.1'); + assert.doesNotThrow(() => assertPublishedStableTags(repoRoot)); + git('tag', '2.0.1'); + assert.throws(() => assertPublishedStableTags(repoRoot), /Refusing to publish local-only stable tag\(s\): 2\.0\.1/); + await assert.rejects(main({ repoRoot, args: [], runRelease: () => assert.fail('Local-only stable tags must block semantic-release.') }), /Refusing to publish local-only stable tag\(s\): 2\.0\.1/); +}); + +test('also refuses local-only stable tags on unmerged branches', (t) => { + const { repoRoot, git } = fixture(t); + git('switch', '-c', 'unmerged'); + git('-c', 'user.name=Contract Test', '-c', 'user.email=contract@example.invalid', 'commit', '--allow-empty', '-m', 'chore: unmerged work'); + git('tag', '2.0.1'); + git('switch', 'main'); + assert.equal(git('tag', '--merged', 'HEAD'), '2.0.0'); + assert.throws(() => assertPublishedStableTags(repoRoot), /Refusing to publish local-only stable tag\(s\): 2\.0\.1/); +}); + +test('parses the existing publish and publish-test command options', () => { + assert.deepEqual(releaseOptions(['--tag-format', '${version}', '--dry-run', '--debug', '--no-ci']), { tagFormat: '${version}', dryRun: true, debug: true, ci: false }); + assert.deepEqual(releaseOptions(['-t', '${version}', '-d']), { tagFormat: '${version}', dryRun: true }); +}); + +test('real semantic-release dry run analyzes a pushed release PR branch and blocks mismatches before tagging', (t) => { + const { repoRoot, remote, git } = fixture(t); + fs.symlinkSync(path.resolve(__dirname, '../../node_modules'), path.join(repoRoot, 'node_modules'), 'dir'); + git('switch', '-c', 'release-2.1.x'); + git('-c', 'user.name=Contract Test', '-c', 'user.email=contract@example.invalid', 'commit', '--allow-empty', '-m', 'feat: add a project capability'); + git('push', 'origin', 'release-2.1.x'); + // semantic-release hooks stdout, so run it outside node:test's event stream. + const script = ` + const assert = require('node:assert/strict'); + const fs = require('node:fs'); + const { main } = require(${JSON.stringify(path.join(__dirname, 'semantic-release.cjs'))}); + const config = require(${JSON.stringify(path.resolve(__dirname, '../../release.config.js'))}); + (async () => { + const { default: semanticRelease } = await import(${JSON.stringify(require.resolve('semantic-release'))}); + const guard = config.plugins.find((plugin) => typeof plugin.verifyRelease === 'function'); + const runRelease = (options, context) => semanticRelease({ + ...config, ...options, repositoryUrl: ${JSON.stringify(remote)}, plugins: [config.plugins[0], guard], + }, context); + const options = { repoRoot: process.cwd(), args: ['--dry-run', '--no-ci'], runRelease }; + assert.equal(await main(options), 0); + fs.writeFileSync('package.json', JSON.stringify({ version: '2.0.1' })); + await assert.rejects(main(options), /Prepared package.json version 2\\.0\\.1.*computed 2\\.1\\.0/); + })().catch((error) => { console.error(error); process.exitCode = 1; }); + `; + // CI provider branch variables and node:test IPC belong to the outer repo. + const env = { PATH: process.env.PATH, HOME: process.env.HOME }; + execFileSync(process.execPath, ['-e', script], { cwd: repoRoot, env, stdio: 'pipe' }); + assert.equal(git('tag', '--list'), '2.0.0'); +}); diff --git a/.github/workflows/theme-readiness.yml b/.github/workflows/theme-readiness.yml index db13c46..1749a92 100644 --- a/.github/workflows/theme-readiness.yml +++ b/.github/workflows/theme-readiness.yml @@ -58,6 +58,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v7 + with: + fetch-depth: 0 - name: Setup Node uses: actions/setup-node@v6 @@ -191,6 +193,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v7 + with: + fetch-depth: 0 - name: Setup Node uses: actions/setup-node@v6 diff --git a/README.md b/README.md index 6ab6cc6..55e4acf 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ # Emulsify WordPress -Emulsify WordPress 2.0.0 is a Timber-first WordPress parent theme for teams building component-driven sites with Emulsify Core 4, Vite, Storybook, and Twig. +Emulsify WordPress is a Timber-first WordPress parent theme for teams building component-driven sites with Emulsify Core 4, Vite, Storybook, and Twig. The parent theme provides the WordPress runtime: theme setup, Timber bootstrapping, Twig namespaces and helpers, template fallbacks, asset loading, and optional block registration. Generated child themes provide the project layer: components, templates, source Sass and JavaScript, compiled assets, and site-specific overrides. diff --git a/UPGRADE.md b/UPGRADE.md index 1659dff..f9efb2c 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -4,6 +4,15 @@ Emulsify WordPress is a parent theme. Generated child themes do not inherit later starter changes, so most upgrades have two parts: update the parent theme, then decide which starter changes to adopt in each generated project. +## Upgrading from 2.0.0 to 2.1.0 + +The release tooling change requires no runtime or generated child theme edits. +Maintainers need a full Git checkout with tags to run release checks. Before +publishing, prepare a release PR that updates root `package.json` and all derived +version metadata together, then confirm semantic-release computes the same +version. A checkout still at the latest tagged version skips publishing. See +the [release PR procedure](docs/release-process.md#release-pr-procedure). + ## Upgrading from 1.x to 2.0.0 2.0.0 is a full rebuild of the WordPress theme around Timber, Emulsify Core 4, diff --git a/docs/release-notes-next.md b/docs/release-notes-next.md index 4eee136..6fccc25 100644 --- a/docs/release-notes-next.md +++ b/docs/release-notes-next.md @@ -1,16 +1,21 @@ -# 2.0.0 Release Notes Draft +# 2.1.0 Release Notes Draft -Status date: 2026-08-02 +Status date: 2026-10-22 ## Version strategy -Latest published tag: none on the stable line. The `release-2.x` branch prepares -the first stable release as `2.0.0`. +Latest published tag: `2.0.0`. This draft tracks the upcoming `2.1.0` release. -Until the `2.0.0` tag exists, `release.config.js` forces the first `main` publish -to a major release type and verifies the computed version is exactly `2.0.0`. -After that tag exists, normal Conventional Commit analysis drives later releases -and this file should be rewritten for the next version. +Root `package.json` remains at the latest stable version until a release PR +bumps it and every derived version surface together. The version contract +accepts the latest stable tag or an untagged next patch, minor, or major, and +requires full Git history and tags. It rejects already-tagged draft versions +and stale claims about the latest release. + +The guarded runner skips publishing when no new version is prepared. Once a +release PR prepares `2.1.0`, semantic-release must compute exactly `2.1.0` from +Conventional Commits before it can publish the committed theme headers, ZIP, +and generated-theme lineage. See the [release process](release-process.md). ## Compatibility notes @@ -24,7 +29,7 @@ and this file should be rewritten for the next version. ## Draft GitHub release notes -Title: `2.0.0` +Title: `2.1.0` ### Added @@ -59,6 +64,10 @@ Title: `2.0.0` ### Fixed +- Release tooling supports prepared releases after `2.0.0`, validates versions + against stable Git tags, and prevents publishing metadata that differs from + the version semantic-release computes. `release:check -- --skip-smoke` now + skips runtime smoke commands as documented. - `style.css` header replacement no longer warns when a header already holds the requested value; only a genuinely missing header is reported. @@ -77,4 +86,4 @@ Title: `2.0.0` WordPress fixture path. - Run `npm run publish-test -- --no-ci` before publishing. - Publish the compatible `@emulsify/core` release before Emulsify WordPress - `2.0.0`. + `2.1.0`. diff --git a/docs/release-process.md b/docs/release-process.md index 4c9296f..3c82487 100644 --- a/docs/release-process.md +++ b/docs/release-process.md @@ -2,11 +2,13 @@ This repository uses semantic-release with Conventional Commits. Releases are prepared from `main`, and Git tags use non-prefixed SemVer such as `2.0.0`. -## Release branch target +## Repository history -The `release-2.x` branch prepares the next stable release as `2.0.0`. Until that stable tag exists, the semantic-release config forces the first `main` publish to use a major release type and then verifies the computed release is exactly `2.0.0`. After the `2.0.0` tag exists, normal Conventional Commit analysis drives later releases. - -Semantic-release excludes prerelease tags when it determines the previous release on a stable branch. The repository publish scripts therefore use a guarded runner that temporarily aliases the latest merged `1.0.0` alpha tag as a local `1.0.0` baseline. The release guard removes that temporary tag before semantic-release can push tags, so only the computed `2.0.0` tag is published. The runner verifies the tag target and also removes the alias on failure. +Release checks require full Git history and tags. Use a full clone, run +`git fetch origin --tags` before preparing a release, and use +`git fetch --unshallow --tags origin` first if the checkout is shallow. CI jobs +that run release checks use `fetch-depth: 0`. The version contract fails clearly +if no stable tag is available; it does not invent a release baseline. ## Commit messages @@ -39,8 +41,45 @@ other version surface is compared against it rather than restating a literal: `release:check` fails when any of these disagree, so a missed bump cannot ship inconsistent generated-theme lineage metadata. -Before publishing a release, update root `package.json`, then run -`npm run release:check -- --skip-smoke` and fix any surface it reports. +The root version must be stable `x.y.z`: either the latest stable tag, or an +untagged next patch, minor, or major of it. For example, after `2.0.0`, the +prepared version may be `2.0.1`, `2.1.0`, or `3.0.0`. Skipped versions, older +tagged versions, and prereleases are rejected. Release-note draft headings must +name an untagged version, and claims about the latest or current release must +match the latest stable tag. + +## Release PR procedure + +1. Start a release PR from an up-to-date `origin/main` with full history and + tags. Review the Conventional Commits since the latest stable tag to choose + the upcoming version. +2. Bump root `package.json` to that prepared version. Update `style.css` + `Version`, `whisk/package.json` version, `whisk/style.css` `Version`, and + `whisk/project.emulsify.json` `project.generatedFromVersion` to match. Keep + lockfile package metadata aligned when applicable. No release plugin writes + these versions for you. +3. Update `docs/release-notes-next.md` and `UPGRADE.md`, then run the local + readiness checks below, leaving `publish-test` for step 4. Include the full + `npm run release:check` with WP-CLI and + MySQL available. The release job packages these committed files, including + their version strings, into the ZIP. +4. Push the release PR branch, then run `npm run publish-test -- --no-ci`. + This explicit local dry run analyzes the current pushed branch; publishing + remains restricted to `main`. Semantic-release must compute exactly + the prepared version from the Conventional Commits. A mismatch fails with + an actionable message; resolve the version or commit classification before + merging. +5. Merge the release PR to `main` after readiness checks pass. The publishing + workflow repeats the checks and publishes only when its computed version + matches the prepared version. +6. After publishing, rotate `docs/release-notes-next.md` to an untagged draft + heading and release title, and update or remove its latest-tag claim. The + contract rejects the shipped draft on subsequent runs until it is rotated. + +When `package.json` still equals the latest stable tag, no release is prepared. +The guarded runner skips publishing successfully, even if newer commits would +otherwise trigger semantic-release. Ordinary change PRs do not need to bump +versions; the release PR prepares them together. ## Release checks @@ -105,6 +144,10 @@ commands still exist in the package where readers are instructed to run them. `release:check` adds static release-readiness checks and the full WordPress fixture smoke path. In CI, the fixture extracts the built `emulsify.zip` into an isolated WordPress site, confirms the packaged `wp emulsify` command, generates and activates a child theme from the packaged Whisk source, adds neutral built asset and block fixtures, renders frontend routes through Timber, fetches those built child assets, and checks ACF/Twig and native `block.json` discovery. It requires WP-CLI and MySQL. It skips gracefully when WP-CLI or database settings are unavailable unless `WP_SMOKE_REQUIRED=1` is set. +Use `npm run release:check -- --skip-smoke` for static checks and focused +contract tests without runtime smoke commands. This is a quick local check; +the release PR and publishing workflow still require the full fixture path. + ## Installable release artifact Run `npm run build:dist` to create `dist-artifact/emulsify.zip`. The build stages an explicit parent-theme runtime file list under a top-level `emulsify/` directory, copies only tracked Whisk generator files, and installs the versions pinned in `composer.lock` with `--no-dev --optimize-autoloader` directly into that staged tree. The ZIP therefore bundles `vendor/` and can be installed without running Composer after download. @@ -133,18 +176,18 @@ Pull requests targeting `main` or `release-2.x` additionally run the two extende Weekly scheduled runs repeat both extended jobs. Manual dispatch runs the WordPress fixture when `wordpress_fixture` is enabled and the Whisk build/audit when `extended_checks` is enabled. The workflow concurrency group cancels superseded pull-request runs, and both extended jobs have explicit runtime limits. -## 2.0 merge and release gate +## Merge and release gate -The two fast pull-request jobs run automatically for every configured target branch. All four jobs run for changes targeting `main` or `release-2.x`. For an additional final rerun before merging the 2.0 release branch: +The two fast pull-request jobs run automatically for every configured target branch. All four jobs run for changes targeting `main` or `release-2.x`. For an additional final rerun before merging a release PR: 1. Open GitHub Actions for `emulsify-ds/emulsify-wordpress`. 2. Select the `WordPress Theme Readiness` workflow. 3. Choose `Run workflow`. -4. Select the `release-2.x` branch. +4. Select the release PR branch. 5. Keep `wordpress_fixture` enabled. It defaults to enabled for manual runs. 6. Enable `extended_checks` to rerun the CI-seeded Storybook and accessibility audit. -Success means `Practical theme readiness`, `PHP coding standards and static analysis`, `WordPress fixture smoke`, and `Extended Whisk Storybook and a11y` pass. The fixture job installs WP-CLI, starts MySQL, sets `WP_SMOKE_REQUIRED=1`, and runs `npm run release:check` against the isolated WordPress fixture. Record the successful workflow run in the 2.0 release PR before merging. +Success means `Practical theme readiness`, `PHP coding standards and static analysis`, `WordPress fixture smoke`, and `Extended Whisk Storybook and a11y` pass. The fixture job installs WP-CLI, starts MySQL, sets `WP_SMOKE_REQUIRED=1`, and runs `npm run release:check` against the isolated WordPress fixture. Record the successful workflow run in the release PR before merging. The semantic-release workflow remains release-gated. It runs release readiness, requires the full WordPress fixture smoke path with WP-CLI and MySQL, runs a semantic-release dry run, and then allows the final publish job only after the readiness job succeeds. Release publishing must not proceed when the full fixture path is skipped or failed. diff --git a/release.config.js b/release.config.js index fee464d..67831b9 100644 --- a/release.config.js +++ b/release.config.js @@ -1,4 +1,5 @@ -const { execFileSync } = require('node:child_process'); +const fs = require('node:fs'); +const path = require('node:path'); const parserOpts = { // semantic-release's default parser does not treat "feat!:" consistently @@ -10,132 +11,23 @@ const parserOpts = { noteKeywords: ['BREAKING CHANGE', 'BREAKING CHANGES', 'BREAKING-CHANGE'], }; -const expectedStableRelease = '2.0.0'; -const initialStableBaseline = '1.0.0'; -const syntheticBaselineEnvironment = 'EMULSIFY_SYNTHETIC_STABLE_BASELINE'; -const syntheticBaselineShaEnvironment = 'EMULSIFY_SYNTHETIC_STABLE_BASELINE_SHA'; -const syntheticBaselineSourceEnvironment = 'EMULSIFY_SYNTHETIC_STABLE_BASELINE_SOURCE'; +const preparedReleaseGuard = { + verifyRelease(pluginConfig, { cwd = __dirname, nextRelease }) { + const preparedVersion = JSON.parse(fs.readFileSync(path.join(cwd, 'package.json'), 'utf8')).version; -function parseVersionParts(version) { - return String(version || '') - .split('-')[0] - .split('.') - .map((part) => Number.parseInt(part, 10)); -} - -function isAtLeastVersion(version, minimumVersion) { - const current = parseVersionParts(version); - const minimum = parseVersionParts(minimumVersion); - - if ( - current.length !== 3 || - minimum.length !== 3 || - current.some(Number.isNaN) || - minimum.some(Number.isNaN) - ) { - return false; - } - - for (let index = 0; index < current.length; index += 1) { - if (current[index] > minimum[index]) { - return true; - } - - if (current[index] < minimum[index]) { - return false; - } - } - - return true; -} - -function removeSyntheticBaseline(cwd) { - const baselineTag = process.env[syntheticBaselineEnvironment]; - const expectedSha = process.env[syntheticBaselineShaEnvironment]; - - if (baselineTag !== initialStableBaseline || !expectedSha) { - return; - } - - try { - const actualSha = execFileSync('git', ['rev-parse', '--verify', `${baselineTag}^{commit}`], { - cwd: cwd || process.cwd(), - encoding: 'utf8', - }).trim(); - - if (actualSha !== expectedSha) { - throw new Error(`${baselineTag} points to ${actualSha}, not the temporary baseline commit ${expectedSha}.`); - } - - execFileSync('git', ['tag', '--delete', baselineTag], { - cwd: cwd || process.cwd(), - stdio: 'ignore', - }); - delete process.env[syntheticBaselineEnvironment]; - delete process.env[syntheticBaselineShaEnvironment]; - } - catch (error) { - throw new Error(`Unable to remove the temporary ${baselineTag} release baseline tag before publishing.`, { - cause: error, - }); - } -} - -const expectedStableReleaseGuard = { - verifyRelease(pluginConfig, { branch, cwd, lastRelease = {}, nextRelease }) { - try { - // The 2.x release branch prepares the stable 2.0.0 baseline. After that - // baseline exists, normal semantic-release versioning can continue. - if (branch.name !== 'main' || isAtLeastVersion(lastRelease.version, expectedStableRelease)) { - return; - } - - if (nextRelease.version !== expectedStableRelease) { - const message = `Expected semantic-release to prepare ${expectedStableRelease} on main, but computed ${nextRelease.version}. Confirm stable baseline tags and breaking-change commits before publishing.`; - throw new Error(message); - } - } - finally { - // semantic-release pushes every local tag. Remove the temporary baseline - // after version calculation, regardless of whether the guard accepts it. - removeSyntheticBaseline(cwd); + if (nextRelease.version !== preparedVersion) { + throw new Error( + `Prepared package.json version ${preparedVersion}, but semantic-release computed ${nextRelease.version}. Update the release PR's package.json and all derived version files to match Conventional Commits, then rerun npm run release:check.`, + ); } }, }; -const expectedStableReleaseAnalyzer = { - analyzeCommits(pluginConfig, { branch, lastRelease = {} }) { - // The release-2.x branch is the first stable release line for the rebuilt - // parent theme. The runner temporarily exposes the latest 1.0.0 alpha as a - // local 1.0.0 baseline so semantic-release can calculate 2.0.0. - return branch.name === 'main' && !isAtLeastVersion(lastRelease.version, expectedStableRelease) - ? 'major' - : null; - }, -}; - -function finalizeReleaseNotesContext(context) { - if ( - context.version === expectedStableRelease - && context.previousTag === initialStableBaseline - && process.env[syntheticBaselineSourceEnvironment]?.startsWith(`${initialStableBaseline}-`) - ) { - // The temporary baseline is never published, so do not emit a compare URL - // whose left-hand tag would not exist on GitHub. - context.linkCompare = false; - } - - return context; -} - module.exports = { - expectedStableRelease, - initialStableBaseline, tagFormat: '${version}', branches: ['main'], repositoryUrl: 'git@github.com:emulsify-ds/emulsify-wordpress.git', plugins: [ - expectedStableReleaseAnalyzer, ['@semantic-release/commit-analyzer', { preset: 'angular', parserOpts }], [ '@semantic-release/release-notes-generator', @@ -144,11 +36,10 @@ module.exports = { parserOpts, writerOpts: { commitsSort: ['subject', 'scope'], - finalizeContext: finalizeReleaseNotesContext, }, }, ], - expectedStableReleaseGuard, + preparedReleaseGuard, [ '@semantic-release/github', { From a085460538d104b31c62c59945f2fc7bdf3785f1 Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Thu, 22 Oct 2026 16:03:02 -0500 Subject: [PATCH 003/100] fix(templates): correct fallback pagination markup and URLs --- .github/scripts/attribute-helper-smoke.php | 5 +--- .github/scripts/release-check.cjs | 2 +- .github/scripts/twig-autoescape-smoke.php | 5 +--- UPGRADE.md | 6 +++++ docs/release-notes-next.md | 5 ++++ includes/Runtime/Twig.php | 16 ++++++----- templates/partials/pagination.twig | 31 ++++++++-------------- 7 files changed, 35 insertions(+), 35 deletions(-) diff --git a/.github/scripts/attribute-helper-smoke.php b/.github/scripts/attribute-helper-smoke.php index b8a3170..9fbd7e3 100644 --- a/.github/scripts/attribute-helper-smoke.php +++ b/.github/scripts/attribute-helper-smoke.php @@ -38,10 +38,7 @@ function emulsify_attribute_helper_assert_same( string $label, $expected, $actua */ function emulsify_register_smoke_twig_functions( \Twig\Environment $environment, Twig $helpers ): void { foreach ( $helpers->functions( array() ) as $name => $definition ) { - $options = array( - 'is_safe' => $definition['is_safe'] ?? array(), - 'needs_context' => $definition['needs_context'] ?? false, - ); + $options = $definition['options'] ?? array(); $environment->addFunction( new \Twig\TwigFunction( $name, $definition['callable'], $options ) ); } diff --git a/.github/scripts/release-check.cjs b/.github/scripts/release-check.cjs index ec8fcd1..87b2823 100644 --- a/.github/scripts/release-check.cjs +++ b/.github/scripts/release-check.cjs @@ -558,7 +558,7 @@ function runStaticChecks() { ensure(twig.includes("$options['autoescape'] = 'html'"), 'Timber integration should enable HTML autoescaping.'); ensure(smoke.includes('{{ fields.heading }}'), 'Twig autoescape smoke should render a field value.'); ensure(smoke.includes(''), 'Twig autoescape smoke should use a hostile script fixture.'); - ensure(smoke.includes("$definition['is_safe']"), 'Twig autoescape smoke should register helper safety metadata.'); + ensure(smoke.includes("$definition['options']"), 'Twig autoescape smoke should register the same helper options as Timber.'); return 'Twig HTML autoescaping is registered and covered by a behavioral smoke test.'; }); diff --git a/.github/scripts/twig-autoescape-smoke.php b/.github/scripts/twig-autoescape-smoke.php index 4d55ac9..3eb9db5 100644 --- a/.github/scripts/twig-autoescape-smoke.php +++ b/.github/scripts/twig-autoescape-smoke.php @@ -58,10 +58,7 @@ function emulsify_twig_autoescape_fail( string $message ): void { new \Twig\TwigFunction( $name, $definition['callable'], - array( - 'is_safe' => $definition['is_safe'] ?? array(), - 'needs_context' => $definition['needs_context'] ?? false, - ) + $definition['options'] ?? array() ) ); } diff --git a/UPGRADE.md b/UPGRADE.md index f9efb2c..6072dce 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -6,6 +6,12 @@ then decide which starter changes to adopt in each generated project. ## Upgrading from 2.0.0 to 2.1.0 +Fallback pagination ellipses remain non-interactive spans with the existing BEM +classes, now displaying a translated `…` and omitting `aria-current`. Child +themes that override `templates/partials/pagination.twig` should adopt these +markup corrections and decode Timber's display-escaped URLs before passing +them to `add_attributes`. The parent adds no frontend CSS. + The release tooling change requires no runtime or generated child theme edits. Maintainers need a full Git checkout with tags to run release checks. Before publishing, prepare a release PR that updates root `package.json` and all derived diff --git a/docs/release-notes-next.md b/docs/release-notes-next.md index 6fccc25..a689385 100644 --- a/docs/release-notes-next.md +++ b/docs/release-notes-next.md @@ -64,6 +64,11 @@ Title: `2.1.0` ### Fixed +- Fallback pagination marks only the actual current page, renders a translated + ellipsis character as non-interactive text, and preserves multi-argument URLs + in numbered, previous, and next links without double escaping. +- Twig helper registration uses Timber's function options so rendered attribute + markup remains intact and helpers receive the intended Twig context. - Release tooling supports prepared releases after `2.0.0`, validates versions against stable Git tags, and prevents publishing metadata that differs from the version semantic-release computes. `release:check -- --skip-smoke` now diff --git a/includes/Runtime/Twig.php b/includes/Runtime/Twig.php index fb9d9a7..479690a 100644 --- a/includes/Runtime/Twig.php +++ b/includes/Runtime/Twig.php @@ -130,15 +130,19 @@ public function loader_paths( $loader ) { */ public function functions( array $functions ): array { $functions['bem'] = array( - 'callable' => array( $this, 'bem' ), - 'needs_context' => true, - 'is_safe' => array( 'html' ), + 'callable' => array( $this, 'bem' ), + 'options' => array( + 'needs_context' => true, + 'is_safe' => array( 'html' ), + ), ); $functions['add_attributes'] = array( - 'callable' => array( $this, 'add_attributes' ), - 'needs_context' => true, - 'is_safe' => array( 'html' ), + 'callable' => array( $this, 'add_attributes' ), + 'options' => array( + 'needs_context' => true, + 'is_safe' => array( 'html' ), + ), ); return $functions; diff --git a/templates/partials/pagination.twig b/templates/partials/pagination.twig index e3927ec..4f13542 100644 --- a/templates/partials/pagination.twig +++ b/templates/partials/pagination.twig @@ -17,7 +17,7 @@ } %} {% set previous_link_attributes = { class: bem('link', [], 'pagination'), - href: pagination.prev.link + href: function('html_entity_decode', pagination.prev.link) } %}
  • {{ function('__', 'Previous', 'emulsify') }} @@ -28,28 +28,19 @@ {% set page_item_attributes = { class: bem('item', [page.current|default(false) ? 'current' : ''], 'pagination') } %} - {% if page.link|default(null) %} - {% set page_link_attributes = { - class: [ - bem('link', [], 'pagination'), - page.class|default('') - ], - href: page.link - } %} - {% else %} - {% set page_link_attributes = { - class: [ - bem('link', [], 'pagination'), - page.class|default('') - ], - 'aria-current': 'page' - } %} - {% endif %} + {% set page_link_attributes = { + class: [ + bem('link', [], 'pagination'), + page.class|default('') + ], + href: page.link|default(null) ? function('html_entity_decode', page.link) : null, + 'aria-current': page.current|default(false) ? 'page' : null + } %}
  • {% if page.link|default(null) %} {{ page.title }} {% else %} - {{ page.title }} + {{ page.class|default('') == 'dots' ? function('__', '…', 'emulsify') : page.title }} {% endif %}
  • {% endfor %} @@ -60,7 +51,7 @@ } %} {% set next_link_attributes = { class: bem('link', [], 'pagination'), - href: pagination.next.link + href: function('html_entity_decode', pagination.next.link) } %}
  • {{ function('__', 'Next', 'emulsify') }} From d564eb89ac52dcdd7f65a99da9e7d5320b50f30f Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Thu, 22 Oct 2026 16:03:11 -0500 Subject: [PATCH 004/100] fix(a11y): mark the current fallback menu item --- UPGRADE.md | 3 +++ docs/release-notes-next.md | 2 ++ templates/partials/menu.twig | 3 ++- 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/UPGRADE.md b/UPGRADE.md index 6072dce..a98561d 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -12,6 +12,9 @@ themes that override `templates/partials/pagination.twig` should adopt these markup corrections and decode Timber's display-escaped URLs before passing them to `add_attributes`. The parent adds no frontend CSS. +Child themes overriding `templates/partials/menu.twig` should also mark only +links whose `item.current` flag is true with `aria-current="page"`. + The release tooling change requires no runtime or generated child theme edits. Maintainers need a full Git checkout with tags to run release checks. Before publishing, prepare a release PR that updates root `package.json` and all derived diff --git a/docs/release-notes-next.md b/docs/release-notes-next.md index a689385..ecdbc67 100644 --- a/docs/release-notes-next.md +++ b/docs/release-notes-next.md @@ -64,6 +64,8 @@ Title: `2.1.0` ### Fixed +- Fallback menus set `aria-current="page"` only on the current menu item, + leaving its ancestors unmarked. - Fallback pagination marks only the actual current page, renders a translated ellipsis character as non-interactive text, and preserves multi-argument URLs in numbered, previous, and next links without double escaping. diff --git a/templates/partials/menu.twig b/templates/partials/menu.twig index baa0faa..a2be383 100644 --- a/templates/partials/menu.twig +++ b/templates/partials/menu.twig @@ -17,7 +17,8 @@ class: bem('link', [], 'menu'), href: item.link|default('#'), target: item.target|default(null), - rel: item.target|default(null) ? 'noopener' : null + rel: item.target|default(null) ? 'noopener' : null, + 'aria-current': item.current|default(false) ? 'page' : null } %}
  • From 3addd33aa8c12d37d3f6ac1d0ff9e242d5bb21e0 Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Thu, 22 Oct 2026 16:03:54 -0500 Subject: [PATCH 005/100] test(templates): cover fallback navigation and search pagination --- .github/scripts/fallback-navigation-smoke.php | 189 ++++++++++++++++++ .github/scripts/pr-validation.cjs | 1 + .github/scripts/release-check.cjs | 1 + .github/scripts/wordpress-fixture-smoke.cjs | 62 +++++- package.json | 1 + 5 files changed, 252 insertions(+), 2 deletions(-) create mode 100644 .github/scripts/fallback-navigation-smoke.php diff --git a/.github/scripts/fallback-navigation-smoke.php b/.github/scripts/fallback-navigation-smoke.php new file mode 100644 index 0000000..807b53a --- /dev/null +++ b/.github/scripts/fallback-navigation-smoke.php @@ -0,0 +1,189 @@ +loadHTML( '' . $html . '' ); + libxml_clear_errors(); + libxml_use_internal_errors( $previous ); + return new DOMXPath( $document ); +} + +/** + * Records an assertion without stopping the other regression checks. + * + * @param bool $condition Assertion result. + * @param string $message Failure explanation. + * @param array $errors Collected failures. + * @return void + */ +function emulsify_navigation_expect( bool $condition, string $message, array &$errors ): void { + if ( ! $condition ) { + $errors[] = $message; + } +} + +$integration = new Twig(); +$loader = new \Twig\Loader\FilesystemLoader(); +$loader->addPath( __DIR__ . '/../../templates', 'templates' ); +$environment = new \Twig\Environment( $loader, $integration->environment_options( array( 'cache' => false ) ) ); + +foreach ( $integration->functions( array() ) as $name => $definition ) { + $environment->addFunction( + new \Twig\TwigFunction( + $name, + $definition['callable'], + $definition['options'] ?? array() + ) + ); +} + +$environment->addFunction( new \Twig\TwigFunction( 'function', array( new \Timber\Twig(), 'exec_function' ) ) ); +$integration->extensions( $environment ); + +$hostile = '">'; +$urls = array( + 'Previous' => '/?s=needle&paged=4&filter=a%26b', + '1' => '/?s=needle&paged=1&filter=a%26b', + $hostile => '/?s=needle&paged=6&filter=a%26b', + 'Next' => '/?s=needle&paged=6&filter=a%26b', +); +$display_url = static function ( string $url ): string { + return str_replace( '&', '&', $url ); +}; +$pagination_context = array( + 'pagination' => array( + 'prev' => array( 'link' => $display_url( $urls['Previous'] ) ), + 'pages' => array( + array( 'class' => 'page-number page-numbers', 'title' => '1', 'link' => $display_url( $urls['1'] ), 'current' => false ), + array( 'class' => 'dots', 'title' => '…' ), + array( 'class' => 'page-number page-numbers current', 'title' => '5', 'current' => true ), + array( 'class' => 'page-number page-numbers', 'title' => $hostile, 'link' => $display_url( $urls[ $hostile ] ), 'current' => false ), + ), + 'next' => array( 'link' => $display_url( $urls['Next'] ) ), + ), +); +$pagination = $environment->render( + '@templates/partials/pagination.twig', + $pagination_context +); +$pager_dom = emulsify_navigation_dom( $pagination ); +$checks = array( + 'pagination current page' => array(), + 'pagination literal ellipsis' => array(), + 'pagination URL escaping' => array(), + 'menu current item' => array(), +); + +$current = $pager_dom->query( '//nav//*[@aria-current="page"]' ); +emulsify_navigation_expect( 1 === $current->length && '5' === trim( $current->item( 0 )->textContent ), 'Only the actual current page must have aria-current="page".', $checks['pagination current page'] ); +emulsify_navigation_expect( 1 === $pager_dom->query( '//li[contains(concat(" ", normalize-space(@class), " "), " pagination__item--current ")]' )->length, 'The current item must preserve its BEM modifier.', $checks['pagination current page'] ); + +// Current state must also work when a pagination provider supplies a link. +$pagination_context['pagination']['pages'][2]['link'] = $display_url( '/?s=needle&paged=5&filter=a%26b' ); +$linked_current_dom = emulsify_navigation_dom( $environment->render( '@templates/partials/pagination.twig', $pagination_context ) ); +$linked_current = $linked_current_dom->query( '//nav//*[@aria-current="page"]' ); +emulsify_navigation_expect( 1 === $linked_current->length && '5' === trim( $linked_current->item( 0 )->textContent ), 'An explicitly current item must be marked even when it supplies a link.', $checks['pagination current page'] ); + +$dots = $pager_dom->query( '//nav//*[contains(concat(" ", normalize-space(@class), " "), " dots ")]' ); +emulsify_navigation_expect( 1 === $dots->length && 'span' === $dots->item( 0 )->nodeName && '…' === trim( $dots->item( 0 )->textContent ), 'Timber ellipsis items must render as non-interactive spans with the literal … character.', $checks['pagination literal ellipsis'] ); +emulsify_navigation_expect( false !== strpos( $pagination, '…' ) && false === strpos( $pagination, '&hellip;' ), 'The ellipsis must not appear as literal entity text.', $checks['pagination literal ellipsis'] ); +emulsify_navigation_expect( in_array( array( '…', 'emulsify' ), $GLOBALS['emulsify_navigation_translations'] ?? array(), true ), 'The literal ellipsis must pass through the theme translation domain.', $checks['pagination literal ellipsis'] ); + +$links = $pager_dom->query( '//nav//a' ); +emulsify_navigation_expect( count( $urls ) === $links->length, 'Previous, numeric, and next pagination links must all render.', $checks['pagination URL escaping'] ); + +foreach ( $links as $link ) { + $title = trim( $link->textContent ); + $actual = $link->getAttribute( 'href' ); + $expected = $urls[ $title ] ?? ''; + parse_str( (string) parse_url( $actual, PHP_URL_QUERY ), $actual_query ); + parse_str( (string) parse_url( $expected, PHP_URL_QUERY ), $expected_query ); + emulsify_navigation_expect( '' !== $expected && $actual === $expected && $actual_query === $expected_query && null === parse_url( $actual, PHP_URL_FRAGMENT ), sprintf( '%s link must decode once to %s; got %s.', $title, $expected, $actual ), $checks['pagination URL escaping'] ); +} + +$raw_url = '/?s=x&paged=2"e=" onmouseover="alert(1)'; +$attributes = $environment->createTemplate( '{{ title }}' )->render( array( 'url' => $raw_url, 'title' => $hostile ) ); +$raw_dom = emulsify_navigation_dom( $attributes ); +$raw_link = $raw_dom->query( '//a' )->item( 0 ); +emulsify_navigation_expect( $raw_url === $raw_link->getAttribute( 'href' ) && $hostile === $raw_link->getAttribute( 'title' ) && ! $raw_link->hasAttribute( 'onmouseover' ) && 0 === $raw_dom->query( '//script' )->length && false !== strpos( $attributes, '&paged=' ), 'AttributeBag must continue escaping raw ampersands, quotes, and hostile titles.', $checks['pagination URL escaping'] ); +emulsify_navigation_expect( 0 === $pager_dom->query( '//script' )->length, 'Pagination titles must remain HTML escaped.', $checks['pagination URL escaping'] ); + +$menu = $environment->render( + '@templates/partials/menu.twig', + array( + 'items' => array( + array( + 'title' => 'Ancestor', + 'link' => '/ancestor/', + 'current' => false, + 'current_item_ancestor' => true, + 'current_item_parent' => true, + 'classes' => array( 'current-menu-ancestor', 'current-menu-parent' ), + 'children' => array( + array( 'title' => 'Current', 'link' => '/current/', 'current' => true ), + ), + ), + array( 'title' => $hostile, 'link' => '/unrelated/?a=1&b=2', 'current' => false ), + ), + ) +); +$menu_dom = emulsify_navigation_dom( $menu ); +$current = $menu_dom->query( '//ul//a[@aria-current="page"]' ); +emulsify_navigation_expect( 1 === $current->length && '/current/' === $current->item( 0 )->getAttribute( 'href' ), 'Only the actual current menu link must have aria-current="page"; ancestors stay unmarked.', $checks['menu current item'] ); +emulsify_navigation_expect( 1 === $menu_dom->query( '//ul[contains(concat(" ", normalize-space(@class), " "), " menu--sub ")]' )->length && 0 === $menu_dom->query( '//script' )->length, 'Nested menu BEM classes and HTML-escaped titles must remain intact.', $checks['menu current item'] ); + +$failed = false; + +foreach ( $checks as $name => $errors ) { + if ( $errors ) { + $failed = true; + fwrite( STDERR, 'FAIL ' . $name . ': ' . implode( ' ', $errors ) . "\n" ); + } else { + echo 'PASS ' . $name . "\n"; + } +} + +exit( $failed ? 1 : 0 ); diff --git a/.github/scripts/pr-validation.cjs b/.github/scripts/pr-validation.cjs index f9147ea..7f05bdd 100644 --- a/.github/scripts/pr-validation.cjs +++ b/.github/scripts/pr-validation.cjs @@ -43,6 +43,7 @@ run('Run editor policy smoke test', 'npm', ['run', 'smoke:editor-policy']); run('Run pattern registry smoke test', 'npm', ['run', 'smoke:patterns']); run('Run parent theme filter smoke test', 'npm', ['run', 'smoke:theme-filters']); run('Run Twig autoescape smoke test', 'npm', ['run', 'smoke:twig-autoescape']); +run('Run fallback navigation smoke test', 'npm', ['run', 'smoke:navigation']); run('Run Twig switch smoke test', 'npm', ['run', 'smoke:twig-switch']); run('Run Twig project namespace smoke test', 'npm', ['run', 'smoke:twig-project-namespace']); run('Install Whisk dependencies', 'npm', ['run', 'whisk:install']); diff --git a/.github/scripts/release-check.cjs b/.github/scripts/release-check.cjs index 87b2823..edfb5f1 100644 --- a/.github/scripts/release-check.cjs +++ b/.github/scripts/release-check.cjs @@ -307,6 +307,7 @@ function runStaticChecks() { '.github/scripts/release-version-contract.cjs', '.github/scripts/release-version-contract.test.cjs', '.github/scripts/semantic-release.test.cjs', + '.github/scripts/fallback-navigation-smoke.php', '.github/scripts/docs-command-check.cjs', '.github/scripts/pr-validation.cjs', '.github/scripts/semantic-release.cjs', diff --git a/.github/scripts/wordpress-fixture-smoke.cjs b/.github/scripts/wordpress-fixture-smoke.cjs index c4a08e0..86f43bf 100755 --- a/.github/scripts/wordpress-fixture-smoke.cjs +++ b/.github/scripts/wordpress-fixture-smoke.cjs @@ -528,10 +528,10 @@ if ( ! $found ) { } function configureDebugLog(wpPath) { + wp(wpPath, ['config', 'set', 'WP_DEBUG', 'true', '--raw']); const configPath = path.join(wpPath, 'wp-config.php'); const config = fs.readFileSync(configPath, 'utf8'); const debugConfig = ` -define( 'WP_DEBUG', true ); define( 'WP_DEBUG_LOG', true ); define( 'WP_DEBUG_DISPLAY', false ); define( 'WP_ENVIRONMENT_TYPE', 'local' ); @@ -595,6 +595,24 @@ function createFixtureContent(wpPath) { '--comment_approved=1', ]); + wp(wpPath, ['eval', String.raw` +update_option( 'permalink_structure', '' ); +update_option( 'posts_per_page', 10 ); +for ( $number = 1; $number <= 11; $number++ ) { + $result = wp_insert_post( array( + 'post_type' => 'post', + 'post_status' => 'publish', + 'post_title' => sprintf( 'PaginationProbe result %02d', $number ), + 'post_content' => sprintf( 'PaginationProbe content %02d', $number ), + 'post_date' => sprintf( '2001-01-%02d 12:00:00', $number ), + ), true ); + if ( is_wp_error( $result ) ) { + fwrite( STDERR, $result->get_error_message() . "\n" ); + exit( 1 ); + } +} +`]); + return { authorId, categoryId, pageId, postId }; } @@ -688,6 +706,45 @@ async function checkGeneratedAssets(baseUrl, themeSlug) { await checkGeneratedAsset(baseUrl, themeSlug, 'dist/components/smoke-acf/css/smoke-acf.css', '.smoke-acf'); } +async function checkSearchPagination(baseUrl) { + const firstPage = await checkRoute(baseUrl, { + name: 'search pagination page 1', + path: '/?s=PaginationProbe', + includes: ['Search results for PaginationProbe', 'PaginationProbe result 11'], + }); + appendLog('search-pagination.html', firstPage); + const nextLink = firstPage.match(/]*\bclass="[^"]*\bpagination__item--next\b[^"]*"[^>]*>\s*]*\bhref="([^"]+)"/); + if (!nextLink) { + const navigation = firstPage.match(/]*>[\s\S]*?<\/nav>/g) || []; + throw new Error(`Search page 1 did not render a next pagination link. Navigation: ${navigation.join('\n') || '(none)'}`); + } + + // Decode HTML attribute ampersands once, as the browser does. A double-escaped + // & must remain a fragment in the URL so the regression cannot pass. + const href = nextLink[1].replace(/&(?:amp|#0*38|#x0*26);/gi, '&'); + const nextUrl = new URL(href, baseUrl); + if ( + nextUrl.origin !== baseUrl || + nextUrl.searchParams.get('s') !== 'PaginationProbe' || + nextUrl.searchParams.get('paged') !== '2' || + nextUrl.hash !== '' + ) { + throw new Error(`Search pagination should retain its search and request page 2; received ${nextUrl.href}.`); + } + + const secondPage = await checkRoute(baseUrl, { + name: 'search pagination page 2', + path: `${nextUrl.pathname}${nextUrl.search}`, + includes: ['Search results for PaginationProbe', 'PaginationProbe result 01'], + }); + if (!/]*\baria-current="page"[^>]*>\s*2\s*<\/span>/.test(secondPage)) { + throw new Error('Following the search pagination link did not render current page 2.'); + } + if (secondPage.includes('PaginationProbe result 11')) { + throw new Error('Search page 2 incorrectly includes the first-page-only result.'); + } +} + async function renderRoutes(wpPath, content, baseUrl, port, themeSlug) { startServer(wpPath, port); await waitForServer(baseUrl); @@ -713,6 +770,7 @@ async function renderRoutes(wpPath, content, baseUrl, port, themeSlug) { await checkRoute(baseUrl, route); } + await checkSearchPagination(baseUrl); await checkGeneratedAssets(baseUrl, themeSlug); } @@ -825,7 +883,7 @@ async function main() { await renderRoutes(wpPath, content, baseUrl, port, generatedChildSlug); stopServer(); - log('Generated and activated a child theme, checked block discovery, loaded assets, and rendered home, page, single, archive, search, author, and 404 routes.'); + log('Generated and activated a child theme, checked block discovery, loaded assets, rendered home, page, single, archive, search, author, and 404 routes, and followed search pagination to page 2.'); dropDatabase(env); if (!keepFixture) { diff --git a/package.json b/package.json index 2bbf13f..29770b8 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "smoke:core-block-twig": "php .github/scripts/core-block-twig-renderer-smoke.php", "smoke:editor-enhancements": "php .github/scripts/editor-enhancements-smoke.php", "smoke:editor-policy": "php .github/scripts/editor-policy-smoke.php", + "smoke:navigation": "php .github/scripts/fallback-navigation-smoke.php", "smoke:patterns": "php .github/scripts/pattern-registry-smoke.php", "smoke:starter-init": "node .github/scripts/wordpress-starter-init-smoke.cjs", "smoke:theme-filters": "php .github/scripts/theme-filters-smoke.php", From ab8b1b4330b6309383de5fcb6705d1bb79318e81 Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Thu, 22 Oct 2026 16:37:45 -0500 Subject: [PATCH 006/100] fix(twig): resolve grouped project components like Emulsify Core --- .../scripts/twig-project-namespace-smoke.php | 135 +++++++++++ .github/scripts/wordpress-fixture-smoke.cjs | 29 ++- UPGRADE.md | 8 + docs/release-notes-next.md | 4 + docs/timber-and-twig-authoring.md | 31 ++- includes/Twig/ProjectComponentLoader.php | 228 ++++++++++++++++-- 6 files changed, 407 insertions(+), 28 deletions(-) diff --git a/.github/scripts/twig-project-namespace-smoke.php b/.github/scripts/twig-project-namespace-smoke.php index a9fbb59..fefbc63 100644 --- a/.github/scripts/twig-project-namespace-smoke.php +++ b/.github/scripts/twig-project-namespace-smoke.php @@ -202,6 +202,7 @@ function emulsify_twig_project_smoke_render( \Twig\Environment $environment, str $invalid_child = $work_root . '/invalid-project-child'; $parent = $work_root . '/parent-theme'; $extra = $work_root . '/extra-components'; +$failures = array(); try { emulsify_twig_project_smoke_write( @@ -235,6 +236,59 @@ function emulsify_twig_project_smoke_render( \Twig\Environment $environment, str emulsify_twig_project_smoke_write( $parent . '/src/components/example-card/example-card.twig', 'Parent example card' ); emulsify_twig_project_smoke_write( $extra . '/filtered/filtered.twig', 'Filtered root' ); + $component_fixtures = array( + 'exact-file.twig' => 'Twig extension after exact file', + 'trailing.twig' => 'Flat trailing name must not win', + 'trailing/.twig' => 'Trailing slash dot Twig candidate', + 'trailing/trailing.twig' => 'Nested trailing name after dot Twig', + 'atoms/button/button.twig' => 'Grouped button', + 'ui/controls/deep-card/deep-card.twig' => 'Deep grouped card', + '01-duplicate/duplicate/duplicate.twig' => 'First sibling duplicate', + '02-duplicate/duplicate/duplicate.twig' => 'Second sibling duplicate', + 'a-depth/deeper/breadth/breadth.twig' => 'Deeper duplicate', + 'z-depth/breadth/breadth.twig' => 'Shallower duplicate', + "\u{1F600}/unicode-order/unicode-order.twig" => 'UTF-16 first duplicate', + "\u{E000}/unicode-order/unicode-order.twig" => 'UTF-8 first duplicate', + 'exact-file' => 'Exact file candidate', + 'direct-priority.twig' => 'Direct before grouped', + 'atoms/direct-priority/direct-priority.twig' => 'Grouped after direct', + 'candidate-order.twig' => 'Flat Twig candidate', + 'candidate-order.html.twig' => 'Flat HTML Twig candidate', + 'candidate-order/candidate-order.twig' => 'Nested Twig candidate', + 'flat-html.html.twig' => 'HTML Twig before nested', + 'flat-html/flat-html.twig' => 'Nested after HTML Twig', + 'nested-order/nested-order.twig' => 'Nested Twig before HTML Twig', + 'nested-order/nested-order.html.twig' => 'Nested HTML Twig after Twig', + 'nested-html/nested-html.html.twig' => 'Nested HTML Twig candidate', + 'nested.html/nested.html.twig' => 'HTML extension stays in nested stem', + 'atoms/grouped-html/grouped-html.html.twig' => 'Grouped HTML Twig candidate', + 'atoms/override/override.twig' => 'Child grouped override', + ); + + foreach ( $component_fixtures as $relative => $contents ) { + emulsify_twig_project_smoke_write( $child . '/src/components/' . $relative, $contents ); + } + + emulsify_twig_project_smoke_write( $parent . '/src/components/override.twig', 'Parent direct override' ); + emulsify_twig_project_smoke_write( $parent . '/src/components/parent-only.twig', 'Parent source root' ); + emulsify_twig_project_smoke_write( $parent . '/components/parent-legacy.twig', 'Parent legacy root' ); + + $skipped_directories = array( 'node_modules', '.git', '.cache', '.vite', '.out', '.coverage', 'dist' ); + foreach ( $skipped_directories as $index => $directory ) { + emulsify_twig_project_smoke_write( $child . '/src/components/' . $directory . '/skipped-' . $index . '/skipped-' . $index . '.twig', 'Skipped component' ); + emulsify_twig_project_smoke_write( $child . '/src/components/ui/' . $directory . '/nested-skip-' . $index . '/nested-skip-' . $index . '.twig', 'Nested skipped component' ); + } + + $outside = $work_root . '/outside-components'; + emulsify_twig_project_smoke_write( $outside . '/escape.twig', 'Outside directory template' ); + emulsify_twig_project_smoke_write( $outside . '/external/external.twig', 'Outside grouped template' ); + if ( ! symlink( $outside, $child . '/src/components/escape' ) || ! symlink( $outside . '/escape.twig', $child . '/src/components/escape-file.twig' ) ) { + throw new RuntimeException( 'Could not create component containment symlink fixtures.' ); + } + if ( ! symlink( $child . '/src/components/badge.twig', $child . '/src/components/inside-file.twig' ) || ! symlink( $child . '/src/components/atoms/button', $child . '/src/components/inside-directory' ) ) { + throw new RuntimeException( 'Could not create contained component symlink fixtures.' ); + } + add_filter( 'emulsify_theme_project_component_roots', static function ( array $roots, string $machine_name ) use ( $extra ): array { @@ -297,6 +351,83 @@ static function ( array $roots, string $machine_name ) use ( $extra ): array { 'Unsafe project component references should be rejected.' ); + $resolution_cases = array( + array( 'whisk:button', 'Grouped button', 'A component name should resolve inside a group.' ), + array( 'whisk:deep-card', 'Deep grouped card', 'A component name should resolve inside two grouping levels.' ), + array( 'whisk:ui/controls/deep-card', 'Deep grouped card', 'Explicit grouping paths should support more than two segments.' ), + array( 'whisk:atoms\\button', 'Grouped button', 'Windows-style separators should normalize before candidate construction.' ), + array( 'whisk:./badge', 'Child badge', 'Safe current-directory segments should normalize within the root.' ), + array( 'whisk:missing/../badge', 'Child badge', 'Contained parent-directory segments should normalize without requiring an existing directory.' ), + array( 'whisk:atoms//button', 'Grouped button', 'Repeated separators should normalize before candidate construction.' ), + array( 'whisk:atoms/button/', 'Grouped button', 'A trailing separator should preserve nested component candidates.' ), + array( 'whisk:trailing/', 'Trailing slash dot Twig candidate', 'Candidate suffixes should be added before normalizing a trailing separator.' ), + array( 'whisk:duplicate', 'First sibling duplicate', 'Duplicate shorthand names should use sorted sibling order.' ), + array( 'whisk:breadth', 'Shallower duplicate', 'Shallower groups should precede alphabetically earlier deeper groups.' ), + array( 'whisk:unicode-order', 'UTF-16 first duplicate', 'Sibling sorting should use UTF-16 code units rather than UTF-8 bytes.' ), + array( 'whisk:exact-file', 'Exact file candidate', 'An exact file should be the first candidate.' ), + array( 'whisk:direct-priority', 'Direct before grouped', 'Direct candidates should precede every grouped candidate in the same root.' ), + array( 'whisk:candidate-order', 'Flat Twig candidate', 'A flat Twig candidate should precede HTML Twig and nested candidates.' ), + array( 'whisk:flat-html', 'HTML Twig before nested', 'A flat HTML Twig candidate should precede nested candidates.' ), + array( 'whisk:nested-order', 'Nested Twig before HTML Twig', 'A nested Twig candidate should precede nested HTML Twig.' ), + array( 'whisk:nested-html', 'Nested HTML Twig candidate', 'Nested HTML Twig candidates should resolve.' ), + array( 'whisk:grouped-html', 'Grouped HTML Twig candidate', 'Grouped HTML Twig candidates should resolve.' ), + array( 'whisk:badge.twig', 'Child badge', 'Explicit Twig extensions should resolve as exact files.' ), + array( 'whisk:nested-order.twig', 'Nested Twig before HTML Twig', 'Nested candidate stems should strip an explicit final Twig extension.' ), + array( 'whisk:nested.html.twig', 'HTML extension stays in nested stem', 'Nested candidate stems should preserve HTML before the final Twig extension.' ), + array( 'whisk:flat-html.html.twig', 'HTML Twig before nested', 'Explicit HTML Twig extensions should resolve as exact files.' ), + array( 'whisk:inside-file', 'Child badge', 'File symlinks contained by the root should remain usable.' ), + array( 'whisk:inside-directory/button.twig', 'Grouped button', 'Explicit directory symlinks contained by the root should remain usable.' ), + array( '@whisk/button', 'Grouped button', 'Project at-namespace syntax should use grouped lookup.' ), + array( 'whisk:override', 'Child grouped override', 'A child grouped component should precede a parent direct component.' ), + array( 'whisk:parent-only', 'Parent source root', 'The parent source root should remain available.' ), + array( 'whisk:parent-legacy', 'Parent legacy root', 'The parent legacy root should remain available.' ), + ); + + foreach ( $resolution_cases as list( $reference, $expected, $message ) ) { + try { + emulsify_twig_project_smoke_assert_same( + $expected, + emulsify_twig_project_smoke_render( $environment, '{% include ' . json_encode( $reference, JSON_UNESCAPED_SLASHES ) . ' %}' ), + $message + ); + } catch ( Throwable $throwable ) { + $failures[] = $message . ' ' . $throwable->getMessage(); + } + } + + $missing_references = array( + 'whisk:escape' => 'Directory symlinks must not escape the component root.', + 'whisk:escape-file' => 'File symlinks must not escape the component root.', + '@whisk/escape-file' => 'Project at-namespace aliases must not escape the component root.', + 'whisk:external' => 'Grouping discovery must not follow directory symlinks.', + 'whisk:../../outside' => 'Lexical traversal must not escape the component root.', + 'whisk:missing/button' => 'Project paths must preserve a missing first segment.', + 'missing/button' => 'Bare paths must preserve a missing first segment.', + 'missing/button/button.twig' => 'Bare explicit template paths must preserve a missing first segment.', + ); + + foreach ( $skipped_directories as $index => $directory ) { + $missing_references[ 'whisk:skipped-' . $index ] = $directory . ' must not be searched as a group.'; + $missing_references[ 'whisk:nested-skip-' . $index ] = $directory . ' must be skipped inside nested groups.'; + } + + foreach ( $missing_references as $reference => $message ) { + try { + emulsify_twig_project_smoke_assert( false === $environment->getLoader()->exists( $reference ), $message ); + } catch ( Throwable $throwable ) { + $failures[] = $throwable->getMessage(); + } + } + + foreach ( array( 'whisk:missing-component' => 'is not defined', 'whisk:../example-card' => 'name "whisk:../example-card" is invalid' ) as $reference => $expected_message ) { + try { + $environment->getLoader()->getSourceContext( $reference ); + $failures[] = 'Missing or invalid project references should throw LoaderError: ' . $reference; + } catch ( \Twig\Error\LoaderError $exception ) { + emulsify_twig_project_smoke_assert( false !== strpos( $exception->getMessage(), $expected_message ), 'Project component loader errors should retain their existing wording.' ); + } + } + emulsify_twig_project_smoke_write( $missing_child . '/src/components/example-card/example-card.twig', 'Missing project example card' ); $missing_environment = emulsify_twig_project_smoke_environment( $missing_child, $parent ); @@ -323,6 +454,10 @@ static function ( array $roots, string $machine_name ) use ( $extra ): array { 'Invalid project.emulsify.json should not break @components.' ); + if ( $failures ) { + throw new RuntimeException( implode( "\n", $failures ) ); + } + echo "Twig project namespace smoke checks passed.\n"; } catch ( Throwable $throwable ) { fwrite( STDERR, $throwable->getMessage() . "\n" ); diff --git a/.github/scripts/wordpress-fixture-smoke.cjs b/.github/scripts/wordpress-fixture-smoke.cjs index 86f43bf..3573527 100755 --- a/.github/scripts/wordpress-fixture-smoke.cjs +++ b/.github/scripts/wordpress-fixture-smoke.cjs @@ -328,9 +328,26 @@ function assertGeneratedChildTheme(themePath, slug) { function installGeneratedChildFixtures(themePath) { const globalDirectory = path.join(themePath, 'dist', 'global'); const acfDirectory = path.join(themePath, 'dist', 'components', 'smoke-acf'); + const groupedDirectory = path.join(themePath, 'src', 'components', 'atoms', 'layout', 'smoke-grouped'); + const themeSlug = path.basename(themePath); fs.mkdirSync(globalDirectory, { recursive: true }); fs.mkdirSync(path.join(acfDirectory, 'css'), { recursive: true }); + fs.mkdirSync(groupedDirectory, { recursive: true }); + fs.writeFileSync( + path.join(groupedDirectory, 'smoke-grouped.html.twig'), + '

    {{ label }}

    \n' + ); + fs.writeFileSync( + path.join(themePath, 'templates', 'single.twig'), + `{% extends '@emulsify-tpl/single.twig' %} +{% block content %} + {{ parent() }} + {% include "${themeSlug}:smoke-grouped" with { label: 'Grouped project component via colon' } only %} + {% include "@${themeSlug}/smoke-grouped" with { label: 'Grouped project component via namespace' } only %} +{% endblock %} +` + ); fs.writeFileSync( path.join(globalDirectory, 'smoke.css'), '.smoke-generated-global { color: inherit; }\n' @@ -759,7 +776,17 @@ async function renderRoutes(wpPath, content, baseUrl, port, themeSlug) { const routes = [ { name: 'home', path: '/', includes: ['Emulsify Smoke', 'Smoke Post'] }, { name: 'page', path: `/?page_id=${content.pageId}`, includes: pageIncludes }, - { name: 'single', path: `/?p=${content.postId}`, includes: ['Smoke Post', 'Smoke post content', 'Smoke comment content'] }, + { + name: 'single', + path: `/?p=${content.postId}`, + includes: [ + 'Smoke Post', + 'Smoke post content', + 'Smoke comment content', + 'Grouped project component via colon', + 'Grouped project component via namespace', + ], + }, { name: 'archive', path: `/?cat=${content.categoryId}`, includes: ['Smoke Category', 'Smoke Post'] }, { name: 'search', path: '/?s=Smoke', includes: ['Search results for Smoke', 'Smoke Post'] }, { name: 'author', path: `/?author=${content.authorId}`, includes: ['Archive of Smoke Author', 'Smoke Post'] }, diff --git a/UPGRADE.md b/UPGRADE.md index a98561d..3c391b5 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -6,6 +6,14 @@ then decide which starter changes to adopt in each generated project. ## Upgrading from 2.0.0 to 2.1.0 +Project component references now resolve components inside group folders and +accept both `project_machine_name:component` and +`@project_machine_name/component`. Resolution follows Emulsify Core, so a flat +`component.twig` now precedes `component/component.twig`; earlier WordPress +versions preferred the latter. Check projects that contain both forms or +duplicate component names, and use an explicit group or template path to keep +the intended choice. See the [component resolution order](docs/timber-and-twig-authoring.md#component-includes). + Fallback pagination ellipses remain non-interactive spans with the existing BEM classes, now displaying a translated `…` and omitting `aria-current`. Child themes that override `templates/partials/pagination.twig` should adopt these diff --git a/docs/release-notes-next.md b/docs/release-notes-next.md index ecdbc67..5803457 100644 --- a/docs/release-notes-next.md +++ b/docs/release-notes-next.md @@ -64,6 +64,10 @@ Title: `2.1.0` ### Fixed +- Project component references now match Emulsify Core's direct candidates and + breadth-first grouped discovery, including `.html.twig` templates and the + `@project_machine_name/component` alias. Each child root is searched before + parent roots, with deterministic duplicate precedence and root containment. - Fallback menus set `aria-current="page"` only on the current menu item, leaving its ancestors unmarked. - Fallback pagination marks only the actual current page, renders a translated diff --git a/docs/timber-and-twig-authoring.md b/docs/timber-and-twig-authoring.md index e3149d3..40e51ce 100644 --- a/docs/timber-and-twig-authoring.md +++ b/docs/timber-and-twig-authoring.md @@ -45,9 +45,34 @@ Install or author project components in the structure defined by the selected Em } only %} ``` -The general form is `project_machine_name:component_name`. +The general form is `project_machine_name:component_name`. Both `project_machine_name:example-card` and `@project_machine_name/example-card` follow Emulsify Core's resolution rules. WordPress searches these compatibility roots in order: -This reference resolves child-first compatibility roots, including `src/components/example-card/example-card.twig`, `src/components/example-card.twig`, `components/example-card/example-card.twig`, and `components/example-card.twig`. One-level grouped names such as `project_machine_name:ui/heading` resolve to paths like `src/components/ui/heading/heading.twig`. +1. Child theme `src/components`. +2. Child theme `components`. +3. Parent theme `src/components`. +4. Parent theme `components`. + +Each root is searched completely before moving to the next. For a requested path `x`, direct candidates are tried in this order: + +1. `x`. +2. `x.twig`. +3. `x.html.twig`. +4. `/.twig`. +5. `/.html.twig`. + +Here, `stem` is the last segment after removing a final `.twig`; an explicit `.html.twig` therefore keeps `.html` in the nested stem. For example, `example-card` can resolve to `example-card.twig`, `example-card.html.twig`, `example-card/example-card.twig`, or `example-card/example-card.html.twig`. + +If no direct candidate exists, the same candidates are tried below group directories in breadth-first order. Sibling directories are sorted by UTF-16 code unit, matching Core's JavaScript ordering. Thus `project_machine_name:button` can find `src/components/atoms/button/button.twig` or a component inside deeper groups. A grouped child component takes precedence over a direct parent component. + +Duplicate names resolve deterministically: the first root wins, direct candidates precede grouped candidates, and shallower groups precede deeper groups. Use an explicit group or template path, such as `project_machine_name:atoms/button/button.twig`, to select an otherwise ambiguous component. + +Group discovery skips `node_modules`, `.git`, `.cache`, `.vite`, `.out`, `.coverage`, and `dist`. It does not follow directory symlinks discovered during the walk. Explicit file or directory symlinks can resolve when both their lexical and real paths remain inside the component root. Internal `.` and `..` segments are normalized like Core; leading traversal outside the root is rejected. + +The project resolver leaves bare paths and unrelated namespaces to the normal Twig loader, preserving every path segment. A missing `atoms/button` never falls back to `molecules/button`. + +Discovery and successful or missing resolutions are memoized for the current request. Direct hits do not scan groups. Group directories are not added to the persistent discovery cache: new groups inside existing groups can leave the root directory's modification time, theme version, and build manifest unchanged, so a persisted index could miss their components. The existing persistent cache continues to serve built `dist/components` discovery. + +In a local PHP 8.3 benchmark with a warm filesystem cache (seven-run medians), the first grouped lookup took about 32 ms for 500 directories and 127 ms for 2,000; 1,000 memoized hits took about 0.15 ms. These synthetic results depend on the component tree and filesystem. A simulated persistent index reduced the first lookup to about 0.3 ms before cache I/O, but safely persisting source directories needs separate invalidation. The legacy `@components/example-card/example-card.twig` namespace remains supported for compatible component libraries, existing projects, shared templates, and migration work: @@ -57,7 +82,7 @@ The legacy `@components/example-card/example-card.twig` namespace remains suppor } only %} ``` -Use `emulsify_theme_project_component_roots` to adjust roots for `project_machine_name:component_name` references. Use `emulsify_theme_twig_namespaces` for advanced runtime-only `@namespace/path.twig` paths. +Use `emulsify_theme_project_component_roots` to adjust roots for both project reference forms. Use `emulsify_theme_twig_namespaces` for advanced runtime-only `@namespace/path.twig` paths. Root-level `components` and `src/components` directories are compatibility paths, not starter requirements. Do not remove existing `@components` includes during migration; both forms are supported. diff --git a/includes/Twig/ProjectComponentLoader.php b/includes/Twig/ProjectComponentLoader.php index 8b89d40..66762b4 100644 --- a/includes/Twig/ProjectComponentLoader.php +++ b/includes/Twig/ProjectComponentLoader.php @@ -19,6 +19,13 @@ final class ProjectComponentLoader implements \Twig\Loader\LoaderInterface { */ private $prefix; + /** + * Project at-namespace prefix. + * + * @var string + */ + private $at_prefix; + /** * Component root paths. * @@ -40,6 +47,20 @@ final class ProjectComponentLoader implements \Twig\Loader\LoaderInterface { */ private $cache = array(); + /** + * Breadth-first grouping directories, memoized for this loader's request. + * + * @var array + */ + private $group_directories = array(); + + /** + * Directory names excluded by Emulsify Core source discovery. + * + * @var array + */ + private const SKIP_DIRECTORIES = array( 'node_modules', '.git', '.cache', '.vite', '.out', '.coverage', 'dist' ); + /** * Constructs the project component loader wrapper. * @@ -48,9 +69,18 @@ final class ProjectComponentLoader implements \Twig\Loader\LoaderInterface { * @param \Twig\Loader\LoaderInterface $loader Wrapped Twig loader. */ public function __construct( string $machine_name, array $roots, \Twig\Loader\LoaderInterface $loader ) { - $this->prefix = $machine_name . ':'; - $this->roots = $roots; - $this->loader = $loader; + $this->prefix = $machine_name . ':'; + $this->at_prefix = '@' . $machine_name . '/'; + $this->roots = array(); + $this->loader = $loader; + + foreach ( $roots as $root ) { + $real = realpath( $root ); + + if ( false !== $real && is_dir( $real ) && is_readable( $real ) ) { + $this->roots[] = str_replace( '\\', '/', $real ); + } + } } /** @@ -213,28 +243,164 @@ private function find_project_template( string $name ): ?string { return null; } - $parts = explode( '/', $component ); - $filename = end( $parts ) . '.twig'; - foreach ( $this->roots as $root ) { - // Support the single-directory component convention first, then the - // older flat component.twig form. Roots are already child-first. - foreach ( array( $component . '/' . $filename, $component . '.twig' ) as $relative ) { - $path = $root . '/' . $relative; - - if ( is_file( $path ) && is_readable( $path ) ) { - $realpath = realpath( $path ); + $path = $this->find_in_directory( $root, $component, $root ); - $this->cache[ $name ] = false !== $realpath ? $realpath : $path; + if ( null === $path ) { + foreach ( $this->group_directories( $root ) as $directory ) { + $path = $this->find_in_directory( $directory, $component, $root ); - return $this->cache[ $name ]; + if ( null !== $path ) { + break; + } } } + + if ( null !== $path ) { + $this->cache[ $name ] = $path; + + return $path; + } + } + + $this->cache[ $name ] = null; + + return null; + } + + /** + * Checks Core's ordered file candidates below one base directory. + * + * @param string $directory Base directory. + * @param string $component Component-relative template name. + * @param string $root Owning component root. + * @return string|null Canonical template path. + */ + private function find_in_directory( string $directory, string $component, string $root ): ?string { + $without_twig = (string) preg_replace( '/\.twig$/i', '', $component ); + $stem = basename( $without_twig ); + $candidates = array_unique( + array( + $component, + $component . '.twig', + $component . '.html.twig', + $without_twig . '/' . $stem . '.twig', + $without_twig . '/' . $stem . '.html.twig', + ) + ); + + foreach ( $candidates as $candidate ) { + $path = $this->normalize_path( rtrim( $directory, '/' ) . '/' . $candidate ); + + if ( ! $this->is_within_root( $root, $path ) || ! is_file( $path ) || ! is_readable( $path ) ) { + continue; + } + + $real = realpath( $path ); + + if ( false !== $real && $this->is_within_root( $root, str_replace( '\\', '/', $real ) ) ) { + return $real; + } } return null; } + /** + * Discovers groups in Core's breadth-first, UTF-16 sibling order. + * + * Directory symlinks are not traversed, matching Node's Dirent.isDirectory(). + * + * @param string $root Canonical component root. + * @return array Group directory paths. + */ + private function group_directories( string $root ): array { + if ( array_key_exists( $root, $this->group_directories ) ) { + return $this->group_directories[ $root ]; + } + + $groups = array(); + $pending = array( $root ); + + for ( $index = 0; isset( $pending[ $index ] ); ++$index ) { + $directory = $pending[ $index ]; + + try { + $entries = new \FilesystemIterator( $directory, \FilesystemIterator::SKIP_DOTS ); + } catch ( \UnexpectedValueException $exception ) { + continue; + } + + $children = array(); + + foreach ( $entries as $entry ) { + $name = $entry->getFilename(); + + if ( $entry->isLink() || ! $entry->isDir() || in_array( $name, self::SKIP_DIRECTORIES, true ) ) { + continue; + } + + $path = str_replace( '\\', '/', $entry->getPathname() ); + $real = realpath( $path ); + + if ( false !== $real && $this->is_within_root( $root, $path ) && $this->is_within_root( $root, str_replace( '\\', '/', $real ) ) ) { + $children[ mb_convert_encoding( $name, 'UTF-16BE', 'UTF-8' ) ] = $path; + } + } + + ksort( $children, SORT_STRING ); + + foreach ( $children as $child ) { + $groups[] = $child; + $pending[] = $child; + } + } + + $this->group_directories[ $root ] = $groups; + + return $groups; + } + + /** + * Checks a normalized absolute path against a canonical root boundary. + * + * @param string $root Component root. + * @param string $path Candidate lexical or real path. + * @return bool TRUE when the path stays inside the root. + */ + private function is_within_root( string $root, string $path ): bool { + $prefix = rtrim( $root, '/' ) . '/'; + + return $path === $root || ( + str_starts_with( $path, $prefix ) + && ! str_starts_with( substr( $path, strlen( $prefix ) ), '..' ) + ); + } + + /** + * Resolves lexical dot segments after each candidate suffix is appended. + * + * @param string $path Absolute path with POSIX separators. + * @return string Normalized absolute path. + */ + private function normalize_path( string $path ): string { + $parts = array(); + + foreach ( explode( '/', $path ) as $part ) { + if ( '' === $part || '.' === $part ) { + continue; + } + + if ( '..' === $part ) { + array_pop( $parts ); + } else { + $parts[] = $part; + } + } + + return ( str_starts_with( $path, '/' ) ? '/' : '' ) . implode( '/', $parts ); + } + /** * Gets the safe project component name from a template reference. * @@ -246,12 +412,28 @@ private function project_component_name( string $name ): ?string { return null; } - $component = substr( $name, strlen( $this->prefix ) ); + $prefix = str_starts_with( $name, $this->prefix ) ? $this->prefix : $this->at_prefix; + $component = str_replace( '\\', '/', substr( $name, strlen( $prefix ) ) ); - if ( 1 !== preg_match( '/^[A-Za-z0-9_-]+(?:\/[A-Za-z0-9_-]+)?$/', $component ) ) { - // Keep project component references intentionally shallow: - // component-name or group/component-name. Legacy deep paths are - // still available through explicit @namespace configuration. + if ( '' === $component || str_contains( $component, "\0" ) || str_starts_with( $component, '/' ) || preg_match( '/^[A-Za-z]:/', $component ) ) { + return null; + } + + $depth = 0; + + foreach ( explode( '/', $component ) as $part ) { + if ( '..' === $part ) { + if ( 0 === $depth ) { + return null; + } + + --$depth; + } elseif ( '' !== $part && '.' !== $part ) { + ++$depth; + } + } + + if ( 0 === $depth && 1 !== preg_match( '/[^.\/]/', $component ) ) { return null; } @@ -265,7 +447,7 @@ private function project_component_name( string $name ): ?string { * @return bool TRUE when the name targets this project namespace. */ private function is_project_reference( string $name ): bool { - return 0 === strncmp( $name, $this->prefix, strlen( $this->prefix ) ); + return str_starts_with( $name, $this->prefix ) || str_starts_with( $name, $this->at_prefix ); } /** @@ -275,9 +457,7 @@ private function is_project_reference( string $name ): bool { * @return string Error message. */ private function project_loader_error( string $name ): string { - $component = substr( $name, strlen( $this->prefix ) ); - - if ( '' === $component || false !== strpos( $component, "\0" ) || null === $this->project_component_name( $name ) ) { + if ( null === $this->project_component_name( $name ) ) { return sprintf( 'Project component template name "%s" is invalid.', $name ); } From 6392ff55380886004021b78d5cb4dd3c37f61d41 Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Thu, 22 Oct 2026 20:58:17 -0500 Subject: [PATCH 007/100] fix(compat): raise the minimum WordPress version to 6.8 --- .github/scripts/release-check.cjs | 31 +++++++++++++++++++-- .github/scripts/wordpress-fixture-smoke.cjs | 2 +- .github/workflows/theme-readiness.yml | 4 +-- README.md | 2 +- UPGRADE.md | 5 +++- docs/release-notes-next.md | 8 +++++- style.css | 4 +-- 7 files changed, 46 insertions(+), 10 deletions(-) diff --git a/.github/scripts/release-check.cjs b/.github/scripts/release-check.cjs index edfb5f1..9d6c71c 100644 --- a/.github/scripts/release-check.cjs +++ b/.github/scripts/release-check.cjs @@ -35,6 +35,16 @@ function semver(value) { return /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(String(value)); } +function wordpressVersionAtLeast(version, minimum) { + const actualParts = version.split('.').map(Number); + const minimumParts = minimum.split('.').map(Number); + for (let index = 0; index < Math.max(actualParts.length, minimumParts.length); index += 1) { + const difference = (actualParts[index] || 0) - (minimumParts[index] || 0); + if (difference !== 0) return difference > 0; + } + return true; +} + function runStaticCheck(name, callback) { // Static checks intentionally inspect files as text. They catch packaging and // documentation regressions without needing a full WordPress install. @@ -492,8 +502,6 @@ function runStaticChecks() { ensure(rootThemeHeader.Version === releaseVersion, `style.css Version should match the ${releaseVersion} release line.`); ensure(rootThemeHeader.License === 'GPL-2.0-only', 'style.css License should be GPL-2.0-only.'); ensure(rootThemeHeader['License URI'] === 'https://www.gnu.org/licenses/old-licenses/gpl-2.0.html', 'style.css License URI should point to GPLv2.'); - ensure(rootThemeHeader['Requires at least'] === '6.7', 'style.css Requires at least should stay aligned to the WordPress baseline.'); - ensure(rootThemeHeader['Tested up to'] === '6.7', 'style.css Tested up to should stay aligned to the WordPress baseline.'); ensure(rootThemeHeader['Requires PHP'] === '8.3', 'style.css Requires PHP should stay aligned to the release baseline.'); ensureParentThemeLanguage('style.css Description', rootThemeHeader.Description); ensure(whiskThemeHeader['Theme Name'] === 'Whisk', 'whisk/style.css Theme Name should be Whisk.'); @@ -507,6 +515,25 @@ function runStaticChecks() { return 'Parent and Whisk WordPress theme headers are coherent with package metadata.'; }); + runStaticCheck('WordPress minimum compatibility', () => { + const minimum = rootThemeHeader['Requires at least']; + ensure(minimum === '6.8', 'style.css Requires at least must be 6.8 for this release.'); + const timberPath = 'vendor/timber/timber/src/Timber.php'; + ensure(fs.existsSync(path.join(repoRoot, timberPath)), 'Installed Timber source is required to verify its WordPress minimum; run composer install.'); + const timberMinimum = readFile(timberPath).match(/\bMINIMUM_WP_VERSION\s*=\s*['"](\d+\.\d+(?:\.\d+)?)['"]/); + ensure(timberMinimum, 'Unable to read installed Timber MINIMUM_WP_VERSION; run composer install and review Timber compatibility.'); + ensure(wordpressVersionAtLeast(minimum, timberMinimum[1]), `style.css Requires at least (${minimum}) is below installed Timber MINIMUM_WP_VERSION (${timberMinimum[1]}); raise the theme minimum before releasing.`); + return `Theme minimum ${minimum} supports installed Timber's WordPress minimum ${timberMinimum[1]}.`; + }); + + runStaticCheck('WordPress tested compatibility', () => { + const tested = rootThemeHeader['Tested up to']; + const minimum = rootThemeHeader['Requires at least']; + ensure(tested === '6.8', 'style.css Tested up to must match the blocking WordPress 6.8 fixture for this release.'); + ensure(wordpressVersionAtLeast(tested, minimum), 'style.css Tested up to must not be lower than Requires at least.'); + return `Tested up to ${tested} matches the blocking fixture and covers theme minimum ${minimum}.`; + }); + runStaticCheck('PHP analysis scope', () => { const phpcsConfig = readFile('phpcs.xml.dist'); const phpstanConfig = readFile('phpstan.neon.dist'); diff --git a/.github/scripts/wordpress-fixture-smoke.cjs b/.github/scripts/wordpress-fixture-smoke.cjs index 3573527..c7c0f64 100755 --- a/.github/scripts/wordpress-fixture-smoke.cjs +++ b/.github/scripts/wordpress-fixture-smoke.cjs @@ -18,7 +18,7 @@ const expectedGeneratedFromVersion = JSON.parse( const required = process.env.WP_SMOKE_REQUIRED === '1'; const keepFixture = process.env.WP_SMOKE_KEEP === '1'; const host = process.env.WP_SMOKE_HOST || '127.0.0.1'; -const wordpressVersion = process.env.WP_SMOKE_WORDPRESS_VERSION || '6.7'; +const wordpressVersion = process.env.WP_SMOKE_WORDPRESS_VERSION || '6.8'; let workDir = null; let logDir = null; let server = null; diff --git a/.github/workflows/theme-readiness.yml b/.github/workflows/theme-readiness.yml index 1749a92..5238da4 100644 --- a/.github/workflows/theme-readiness.yml +++ b/.github/workflows/theme-readiness.yml @@ -168,11 +168,11 @@ jobs: strategy: fail-fast: false matrix: - # 6.7 is the declared baseline in style.css. `latest` is an advisory + # 6.8 is the declared baseline in style.css. `latest` is an advisory # forward-compatibility lane so a WordPress release cannot break the # theme silently between scheduled runs. include: - - wordpress-version: '6.7' + - wordpress-version: '6.8' experimental: false - wordpress-version: latest experimental: true diff --git a/README.md b/README.md index 55e4acf..517dc79 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ Use the named `emulsify.zip` asset, not GitHub's automatically generated **Sourc ## Requirements -- WordPress 6.7 or newer. +- WordPress 6.8 or newer. - PHP 8.3 or newer. - Composer 2 for Composer-based installation and parent-theme maintenance. - Node.js 24. Root release tooling expects `>=24.10`; generated child themes expect `>=24`. diff --git a/UPGRADE.md b/UPGRADE.md index 3c391b5..0efafd0 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -6,6 +6,9 @@ then decide which starter changes to adopt in each generated project. ## Upgrading from 2.0.0 to 2.1.0 +The minimum WordPress version is now 6.8, matching the locked Timber dependency. +Sites running WordPress 6.7 must update WordPress before updating the theme. + Project component references now resolve components inside group folders and accept both `project_machine_name:component` and `@project_machine_name/component`. Resolution follows Emulsify Core, so a flat @@ -39,7 +42,7 @@ Step-by-step guidance lives in ### Requirements -- WordPress 6.7 or newer. +- WordPress 6.8 or newer. - PHP 8.3 or newer. - Timber 2.3 or newer, available to the parent theme. - Node.js 24 or newer for generated child theme frontend tooling. Root release diff --git a/docs/release-notes-next.md b/docs/release-notes-next.md index 5803457..cbcc588 100644 --- a/docs/release-notes-next.md +++ b/docs/release-notes-next.md @@ -19,7 +19,7 @@ and generated-theme lineage. See the [release process](release-process.md). ## Compatibility notes -- WordPress 6.7 or newer; PHP 8.3 or newer. +- WordPress 6.8 or newer; PHP 8.3 or newer. - Timber `^2.3` is required by the parent theme. - Generated child themes use Emulsify Core `^4.3.2` and the Vite build workflow. - Generated child themes keep `Template: emulsify`; `whisk` remains a @@ -62,6 +62,12 @@ Title: `2.1.0` - `docs-command-check.cjs` now exports a reusable validator and can validate a real generated child theme directory, not just repository documentation. +### Compatibility + +- The minimum WordPress version is now 6.8, matching the locked Timber 2.5.1 + requirement. Sites on WordPress 6.7 must update WordPress before updating the + theme. The blocking fixture and `Tested up to` header now target 6.8. + ### Fixed - Project component references now match Emulsify Core's direct candidates and diff --git a/style.css b/style.css index ca4b220..bda4113 100644 --- a/style.css +++ b/style.css @@ -3,8 +3,8 @@ * Author: Callin Mullaney & Mike Goulding * Description: Timber-first WordPress parent theme for generated child themes with Emulsify Core 4, Vite, Storybook, and Twig. * Text Domain: emulsify - * Requires at least: 6.7 - * Tested up to: 6.7 + * Requires at least: 6.8 + * Tested up to: 6.8 * Requires PHP: 8.3 * Version: 2.0.0 * License: GPL-2.0-only From a67113b8f4b3a9eb1ecde36509f0af70740b2445 Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Thu, 22 Oct 2026 21:08:36 -0500 Subject: [PATCH 008/100] fix(theme): remove link state styles that never applied --- UPGRADE.md | 7 +++++++ docs/release-notes-next.md | 4 ++++ theme.json | 15 --------------- 3 files changed, 11 insertions(+), 15 deletions(-) diff --git a/UPGRADE.md b/UPGRADE.md index 0efafd0..a5c03d7 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -9,6 +9,13 @@ then decide which starter changes to adopt in each generated project. The minimum WordPress version is now 6.8, matching the locked Timber dependency. Sites running WordPress 6.7 must update WordPress before updating the theme. +The parent no longer emits link hover, focus, and active color declarations +that referenced an undefined custom property. Those declarations caused color +inheritance; removing them can reveal existing link colors. In the default +fixture, focused links keep their normal color instead of inheriting the +surrounding text color. Review child theme link-state styles if they relied on +that inheritance. + Project component references now resolve components inside group folders and accept both `project_machine_name:component` and `@project_machine_name/component`. Resolution follows Emulsify Core, so a flat diff --git a/docs/release-notes-next.md b/docs/release-notes-next.md index cbcc588..963c42f 100644 --- a/docs/release-notes-next.md +++ b/docs/release-notes-next.md @@ -70,6 +70,10 @@ Title: `2.1.0` ### Fixed +- Removed link hover, focus, and active color rules that referenced an undefined + custom property instead of introducing replacement link-state colors. This + can reveal existing link colors where the invalid declarations previously + caused inheritance; see [upgrade guidance](../UPGRADE.md#upgrading-from-200-to-210). - Project component references now match Emulsify Core's direct candidates and breadth-first grouped discovery, including `.html.twig` templates and the `@project_machine_name/component` alias. Each child root is searched before diff --git a/theme.json b/theme.json index 97101d9..343e32c 100644 --- a/theme.json +++ b/theme.json @@ -526,26 +526,11 @@ "color": { "text": "var(--wp--preset--color--brand-primary-light)" }, - ":hover": { - "color": { - "text": "var(---wp--preset--color--brand-primary)" - } - }, - ":focus": { - "color": { - "text": "var(---wp--preset--color--brand-primary)" - } - }, ":visited": { "color": { "text": "var(--wp--preset--color--brand-primary-dark)" } }, - ":active": { - "color": { - "text": "var(---wp--preset--color--brand-primary)" - } - }, "spacing": {}, "typography": {} }, From f7075d810372c95bd77056744ba4cba8b7ee60d7 Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Thu, 22 Oct 2026 21:15:00 -0500 Subject: [PATCH 009/100] build(deps): update Whisk to Emulsify Core 4.5.0 --- .github/scripts/release-check.cjs | 8 ++++---- README.md | 2 +- UPGRADE.md | 23 +++++++++++++++++++++-- docs/release-notes-next.md | 7 +++++-- whisk/README.md | 5 ++++- whisk/docs/development.md | 2 +- whisk/docs/support-information.md | 2 +- whisk/package.json | 2 +- 8 files changed, 38 insertions(+), 13 deletions(-) diff --git a/.github/scripts/release-check.cjs b/.github/scripts/release-check.cjs index 9d6c71c..930b77f 100644 --- a/.github/scripts/release-check.cjs +++ b/.github/scripts/release-check.cjs @@ -923,14 +923,14 @@ function runStaticChecks() { ensure(!fs.existsSync(path.join(repoRoot, 'whisk/whisk.info.yml')), 'Whisk should not add Drupal-style .info.yml metadata.'); ensure(!fs.existsSync(path.join(repoRoot, 'whisk/scripts/vite-if-inputs.mjs')), 'Whisk should not wrap Emulsify Core Vite build errors.'); ensure(whiskPackage.dependencies && whiskPackage.dependencies['@emulsify/core'], 'whisk/package.json must declare @emulsify/core.'); - // Accept any caret range on Core 4 at or above the component-inspector - // minimum, so a patch or minor Core bump does not require editing this file. + // Accept any caret range at or above Core 4.5, so a patch or minor Core + // bump does not require editing this file. const coreRange = whiskPackage.dependencies['@emulsify/core']; const coreRangeMatch = /^\^4\.(\d+)\.(\d+)$/.exec(coreRange); ensure(coreRangeMatch, `whisk/package.json should target a caret range within Emulsify Core 4; found ${coreRange}.`); ensure( - Number(coreRangeMatch[1]) > 3 || (Number(coreRangeMatch[1]) === 3 && Number(coreRangeMatch[2]) >= 0), - `whisk/package.json should target Emulsify Core ^4.3.0 or newer within Core 4; found ${coreRange}.`, + Number(coreRangeMatch[1]) >= 5, + `whisk/package.json should target Emulsify Core ^4.5.0 or newer within Core 4; found ${coreRange}.`, ); ensure(scripts['inspect:components'] === 'emulsify-inspect-components', 'whisk/package.json should expose the published Emulsify Core component inspector binary.'); ensure(!scripts['inspect:components'].includes('node_modules/'), 'whisk/package.json should not deep-link to the component inspector implementation.'); diff --git a/README.md b/README.md index 517dc79..ffd7cff 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ Use the named `emulsify.zip` asset, not GitHub's automatically generated **Sourc - WordPress 6.8 or newer. - PHP 8.3 or newer. - Composer 2 for Composer-based installation and parent-theme maintenance. -- Node.js 24. Root release tooling expects `>=24.10`; generated child themes expect `>=24`. +- Node.js 24.13.0 or newer for generated child themes: their `package.json` advertises `>=24`, but Emulsify Core 4.5.0 requires `>=24.13.0`. Root release tooling separately requires `>=24.10`. Both `.nvmrc` files select the Node 24 line without pinning a minor version. - Timber 2, installed by the site project or bundled in the manual release ZIP. - WP-CLI when generating child themes or running the full WordPress fixture smoke test. diff --git a/UPGRADE.md b/UPGRADE.md index a5c03d7..54026b4 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -9,6 +9,25 @@ then decide which starter changes to adopt in each generated project. The minimum WordPress version is now 6.8, matching the locked Timber dependency. Sites running WordPress 6.7 must update WordPress before updating the theme. +New child themes use Emulsify Core `^4.5.0`. Existing generated themes do not +inherit copied starter scripts or configuration when the parent is updated. +Updating Core is optional for existing projects; to adopt 4.5.0, change the +dependency and refresh your project's lockfile with `npm install`: + +```diff + "dependencies": { +- "@emulsify/core": "^4.3.2" ++ "@emulsify/core": "^4.5.0" + } +``` + +Core requires Node.js 24.13.0 or newer, even though the generated theme advertises +`>=24` and `.nvmrc` selects only the Node 24 line. Check the resolved package's +engines after updates. Core 4.5.0 adds bounded accessibility concurrency and an +optional `@emulsify/core/a11y/wcag22` preset. Its Twig resolver preserves every +segment of bare paths: correct references that relied on a missing first +segment falling back to another group. + The parent no longer emits link hover, focus, and active color declarations that referenced an undefined custom property. Those declarations caused color inheritance; removing them can reveal existing link colors. In the default @@ -52,7 +71,7 @@ Step-by-step guidance lives in - WordPress 6.8 or newer. - PHP 8.3 or newer. - Timber 2.3 or newer, available to the parent theme. -- Node.js 24 or newer for generated child theme frontend tooling. Root release +- Node.js 24.13.0 or newer for generated child theme frontend tooling. Root release tooling requires Node.js 24.10 or newer. - Generated child themes keep `Template: emulsify` in `style.css` so WordPress loads the parent runtime. @@ -81,7 +100,7 @@ Step-by-step guidance lives in ### Package changes - Added: `timber/timber` `^2.3` as a parent theme requirement. -- Added: `@emulsify/core` `^4.3.2` in generated child themes. +- Added: `@emulsify/core` `^4.5.0` in generated child themes. - Added: PHPCS, PHPStan, WPCS, and WordPress/ACF/WP-CLI stubs as parent theme development requirements. - Removed: Webpack-era build dependencies from the starter. diff --git a/docs/release-notes-next.md b/docs/release-notes-next.md index 963c42f..09e3a42 100644 --- a/docs/release-notes-next.md +++ b/docs/release-notes-next.md @@ -21,11 +21,11 @@ and generated-theme lineage. See the [release process](release-process.md). - WordPress 6.8 or newer; PHP 8.3 or newer. - Timber `^2.3` is required by the parent theme. -- Generated child themes use Emulsify Core `^4.3.2` and the Vite build workflow. +- Generated child themes use Emulsify Core `^4.5.0` and the Vite build workflow. - Generated child themes keep `Template: emulsify`; `whisk` remains a generation-only starter source and must not be activated directly. - Root release tooling requires Node.js `>=24.10`; generated child theme tooling - requires Node.js `>=24`. + advertises Node.js `>=24`, but Core requires `>=24.13.0`, the effective floor. ## Draft GitHub release notes @@ -51,6 +51,9 @@ Title: `2.1.0` ### Changed +- New generated themes use Emulsify Core `^4.5.0`; their effective Node.js floor + is 24.13.0. Existing projects should refresh their own dependency lockfiles + when adopting this optional Core update. - Root `package.json` is now the single source of truth for the release version. Theme headers, starter metadata, generated lineage, and smoke assertions all derive from it instead of restating a literal. diff --git a/whisk/README.md b/whisk/README.md index 85fe783..ee8898c 100644 --- a/whisk/README.md +++ b/whisk/README.md @@ -16,7 +16,10 @@ Shared tooling comes from Emulsify Core; the project owns its templates and comp ## Quick start -Use Node.js 24 or newer. If you use nvm, select the recommended version first: +Use Node.js 24.13.0 or newer for Emulsify Core 4.5.0. This theme advertises +`>=24`, but Core's `>=24.13.0` requirement sets the effective minimum. +`.nvmrc` selects the Node 24 line without pinning a minor version. Check the +resolved Core package's `engines` after dependency updates. If you use nvm: ```bash nvm install diff --git a/whisk/docs/development.md b/whisk/docs/development.md index 8246bb1..d9758a3 100644 --- a/whisk/docs/development.md +++ b/whisk/docs/development.md @@ -6,7 +6,7 @@ This guide covers local setup, the frontend workflow, and what this project owns - An existing WordPress site with the Emulsify parent theme and this generated theme installed in `wp-content/themes/%%EMULSIFY_MACHINE_NAME%%` (or the equivalent themes directory). - The Timber plugin or Composer package available to the parent theme. The parent theme owns Timber bootstrapping, Twig namespaces, and route fallbacks. -- Node.js 24 or newer. `package.json` declares the supported range and `.nvmrc` records the recommended version. +- Node.js 24.13.0 or newer for Emulsify Core 4.5.0. The theme advertises `>=24`, but Core requires `>=24.13.0`; `.nvmrc` selects the Node 24 line without pinning a minor version. Check the resolved Core package's `engines` after dependency updates. - nvm is optional but keeps the Node version aligned with the project. - npm, which ships with Node.js. diff --git a/whisk/docs/support-information.md b/whisk/docs/support-information.md index 34dae21..a69b73d 100644 --- a/whisk/docs/support-information.md +++ b/whisk/docs/support-information.md @@ -4,7 +4,7 @@ Work through the troubleshooting list first. If the problem persists, collect th ## Troubleshooting -- **Wrong Node.js version:** run `nvm use`, then compare `node --version` with `.nvmrc` and the `engines.node` range in `package.json`. +- **Wrong Node.js version:** run `nvm use` and compare `node --version` with the resolved Emulsify Core package's `engines.node`. Core 4.5.0 requires at least 24.13.0 even though the theme advertises `>=24`; `.nvmrc` selects only the Node 24 line. - **Missing package or command:** rerun `npm install`. A generated theme has no lockfile until the project commits one. - **WordPress cannot find project assets:** confirm a component library is installed and that a build has produced output under `dist/`. The parent theme enqueues discovered build output; it does not invent asset paths. - **The parent theme is not being used:** confirm the `Template` header in `style.css` names the installed Emulsify parent theme directory and that the parent theme is present in the themes directory. diff --git a/whisk/package.json b/whisk/package.json index 0975ce9..cc8bf0c 100644 --- a/whisk/package.json +++ b/whisk/package.json @@ -40,7 +40,7 @@ "vite": "vite build --watch --config node_modules/@emulsify/core/config/vite/vite.config.js" }, "dependencies": { - "@emulsify/core": "^4.3.2" + "@emulsify/core": "^4.5.0" }, "overrides": { "glob": "^13.0.6", From 015b0df071bb884836067009cee5d92e9c87b2a3 Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Thu, 22 Oct 2026 22:09:48 -0500 Subject: [PATCH 010/100] fix(whisk): align copied scripts and project test runners --- .../ci-readiness/ci-readiness.stories.js | 21 +- .../whisk-a11y/ci-readiness/jest-untested.mjs | 1 + .../whisk-a11y/ci-readiness/jest-value.js | 1 + .../ci-readiness/jest-value.test.js | 5 + .github/scripts/a11y-runner.test.cjs | 185 ++++++++++++++++++ .github/scripts/jest-project-smoke.cjs | 82 ++++++++ .github/scripts/lint-wrappers.test.cjs | 34 ++++ .github/scripts/pr-validation.cjs | 3 + .github/scripts/release-check.cjs | 22 ++- .../scripts/wordpress-starter-init-smoke.cjs | 21 +- README.md | 2 +- UPGRADE.md | 56 ++++++ docs/core-4-vite-workflow.md | 2 +- docs/release-notes-next.md | 9 + package.json | 3 + whisk/config/emulsify-core/run-a11y.js | 119 ++--------- whisk/config/jest.config.js | 13 +- whisk/docs/development.md | 12 +- whisk/package.json | 10 +- 19 files changed, 466 insertions(+), 135 deletions(-) create mode 100644 .github/fixtures/whisk-a11y/ci-readiness/jest-untested.mjs create mode 100644 .github/fixtures/whisk-a11y/ci-readiness/jest-value.js create mode 100644 .github/fixtures/whisk-a11y/ci-readiness/jest-value.test.js create mode 100644 .github/scripts/a11y-runner.test.cjs create mode 100644 .github/scripts/jest-project-smoke.cjs create mode 100644 .github/scripts/lint-wrappers.test.cjs diff --git a/.github/fixtures/whisk-a11y/ci-readiness/ci-readiness.stories.js b/.github/fixtures/whisk-a11y/ci-readiness/ci-readiness.stories.js index b2d1ee5..65cd72b 100644 --- a/.github/fixtures/whisk-a11y/ci-readiness/ci-readiness.stories.js +++ b/.github/fixtures/whisk-a11y/ci-readiness/ci-readiness.stories.js @@ -1,11 +1,30 @@ +import { renderTwig } from '@emulsify/core/storybook'; + import template from './ci-readiness.twig'; +const render = renderTwig(template); + export default { title: 'CI/Readiness fixture', }; export const Default = { - render: (args) => template(args), + render: (args) => render({ ...args, link_text: '' }), + // Anchor this CI-only readiness delay to Pa11y injection: navigation's + // network-idle wait must not consume the delay before pa11y.wait begins. + play: async ({ canvasElement, args }) => { + await new Promise((resolve) => { + const waitForPa11y = () => { + if (window.__pa11y) { + setTimeout(resolve, 750); + } else { + requestAnimationFrame(waitForPa11y); + } + }; + waitForPa11y(); + }); + canvasElement.querySelector('a').textContent = args.link_text; + }, args: { title: 'CI accessibility readiness', summary: diff --git a/.github/fixtures/whisk-a11y/ci-readiness/jest-untested.mjs b/.github/fixtures/whisk-a11y/ci-readiness/jest-untested.mjs new file mode 100644 index 0000000..59c5975 --- /dev/null +++ b/.github/fixtures/whisk-a11y/ci-readiness/jest-untested.mjs @@ -0,0 +1 @@ +export const unusedProjectValue = () => 17; diff --git a/.github/fixtures/whisk-a11y/ci-readiness/jest-value.js b/.github/fixtures/whisk-a11y/ci-readiness/jest-value.js new file mode 100644 index 0000000..7f06807 --- /dev/null +++ b/.github/fixtures/whisk-a11y/ci-readiness/jest-value.js @@ -0,0 +1 @@ +export const projectValue = 42; diff --git a/.github/fixtures/whisk-a11y/ci-readiness/jest-value.test.js b/.github/fixtures/whisk-a11y/ci-readiness/jest-value.test.js new file mode 100644 index 0000000..e3d0e6e --- /dev/null +++ b/.github/fixtures/whisk-a11y/ci-readiness/jest-value.test.js @@ -0,0 +1,5 @@ +import { projectValue } from './jest-value.js'; + +it('executes an ESM import from project source', () => { + expect(projectValue).toBe(42); +}); diff --git a/.github/scripts/a11y-runner.test.cjs b/.github/scripts/a11y-runner.test.cjs new file mode 100644 index 0000000..72396a1 --- /dev/null +++ b/.github/scripts/a11y-runner.test.cjs @@ -0,0 +1,185 @@ +#!/usr/bin/env node + +// Exercise the real Core runner with a controlled Pa11y boundary. Browser-based +// coverage uses the CI Storybook fixture and the generated theme's a11y command. +const assert = require('node:assert/strict'); +const { spawnSync } = require('node:child_process'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { test } = require('node:test'); + +const repoRoot = path.resolve(__dirname, '../..'); +const coreRoot = + process.env.EMULSIFY_CORE_ROOT || + path.join(repoRoot, 'whisk/node_modules/@emulsify/core'); + +function write(file, contents) { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, contents); +} + +function runFixture(storyIds) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'emulsify-a11y-runner-')); + try { + write(path.join(root, 'package.json'), '{"type":"module"}'); + const installedCore = path.join(root, 'node_modules/@emulsify/core'); + write(path.join(installedCore, 'package.json'), '{"type":"module"}'); + for (const file of ['scripts/a11y.js', 'config/a11y.config.js']) { + write( + path.join(installedCore, file), + fs.readFileSync(path.join(coreRoot, file)), + ); + } + write( + path.join(root, 'config/emulsify-core/run-a11y.js'), + fs.readFileSync( + path.join(repoRoot, 'whisk/config/emulsify-core/run-a11y.js'), + ), + ); + write( + path.join(root, 'config/emulsify-core/a11y.config.js'), + `export default ${JSON.stringify({ + concurrency: 2, + storybookBuildDir: '.out', + components: [], + ignore: { codes: ['fixture.ignored'] }, + pa11y: { wait: 321 }, + })};\n`, + ); + write( + path.join(root, '.out/index.json'), + JSON.stringify({ + entries: Object.fromEntries( + storyIds.map((id) => [id, { id, type: 'story' }]), + ), + }), + ); + write( + path.join(root, '.out/iframe.html'), + '
    Served from the configured .out directory.
    ', + ); + write( + path.join(root, 'node_modules/pa11y/package.json'), + '{"type":"module","exports":"./index.js"}', + ); + write( + path.join(root, 'node_modules/pa11y/index.js'), + ` +import fs from 'node:fs'; +let active = 0; +const record = (entry) => fs.appendFileSync('pa11y-calls.jsonl', JSON.stringify(entry) + '\\n'); +export default async function pa11y(url, options) { + const storyId = new URL(url).searchParams.get('id'); + active += 1; + record({ storyId, active, options }); + try { + const response = await fetch(url); + if (!response.ok || !(await response.text()).includes('configured .out directory')) { + throw new Error('The configured Storybook build was not served.'); + } + await new Promise((resolve) => setTimeout(resolve, storyId === 'clean-first' ? 60 : 20)); + if (storyId.startsWith('failed-')) throw new Error('fixture navigation failure: ' + storyId); + return { pageUrl: url, issues: [ + { code: 'fixture.ignored', type: 'error', message: 'Ignored fixture issue' }, + ...(storyId === 'findings' ? [{ code: 'fixture.reported', type: 'error', message: 'Reported fixture issue' }] : []), + ] }; + } finally { + active -= 1; + } +} +`, + ); + const result = spawnSync( + process.execPath, + ['config/emulsify-core/run-a11y.js'], + { + cwd: root, + encoding: 'utf8', + timeout: 15000, + env: { ...process.env, CI: 'true' }, + }, + ); + assert.ifError(result.error); + assert.equal( + result.signal, + null, + 'The runner must close its HTTP server after every outcome.', + ); + const callsPath = path.join(root, 'pa11y-calls.jsonl'); + const calls = fs.existsSync(callsPath) + ? fs.readFileSync(callsPath, 'utf8').trim().split('\n').map(JSON.parse) + : []; + return { ...result, calls }; + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +} + +test('accessibility runner rejects zero discovered stories before starting scans', () => { + const result = runFixture([]); + assert.equal(result.status, 1); + assert.match( + result.stderr, + /No Storybook stories were discovered; accessibility checks did not run/, + ); + assert.deepEqual(result.calls, []); +}); + +test('accessibility runner delegates configured HTTP scans and bounded workers to Core', () => { + const result = runFixture(['clean-first', 'clean-second', 'clean-third']); + assert.equal(result.status, 0, result.stderr); + assert.deepEqual( + result.calls.map(({ storyId }) => storyId), + ['clean-first', 'clean-second', 'clean-third'], + ); + assert.equal(Math.max(...result.calls.map(({ active }) => active)), 2); + for (const { options } of result.calls) { + assert.equal( + options.wait, + 321, + 'The project Pa11y wait must reach every scan.', + ); + assert.deepEqual(options.runners, ['axe']); + assert.ok( + !options.chromeLaunchConfig?.args?.some((arg) => + ['--no-sandbox', '--disable-setuid-sandbox'].includes(arg), + ), + ); + } + assert.match( + result.stdout, + /Accessibility summary: 3 attempted, 3 clean, 0 with findings, 0 failed to execute/, + ); + assert.ok( + result.stdout.indexOf('?id=clean-first') < + result.stdout.indexOf('?id=clean-second'), + 'Reports retain selected-story order despite completion order.', + ); +}); + +test('accessibility runner reports every finding and execution failure before closing', () => { + const result = runFixture([ + 'failed-first', + 'findings', + 'clean-last', + 'failed-last', + ]); + assert.equal(result.status, 1); + assert.deepEqual( + result.calls.map(({ storyId }) => storyId), + ['failed-first', 'findings', 'clean-last', 'failed-last'], + ); + assert.match(result.stdout, /Reported fixture issue/); + assert.doesNotMatch(result.stdout, /Ignored fixture issue/); + assert.match( + result.stdout, + /Accessibility summary: 4 attempted, 1 clean, 1 with findings, 2 failed to execute/, + ); + assert.match( + result.stderr, + /AggregateError: Accessibility execution failed for 2 stories/, + ); + assert.match(result.stdout, /fixture navigation failure: failed-first/); + assert.match(result.stdout, /fixture navigation failure: failed-last/); +}); diff --git a/.github/scripts/jest-project-smoke.cjs b/.github/scripts/jest-project-smoke.cjs new file mode 100644 index 0000000..558862d --- /dev/null +++ b/.github/scripts/jest-project-smoke.cjs @@ -0,0 +1,82 @@ +#!/usr/bin/env node + +const assert = require('node:assert/strict'); +const { spawnSync } = require('node:child_process'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +const repoRoot = path.resolve(__dirname, '../..'); +const starter = path.join(repoRoot, 'whisk'); +const modules = path.join(starter, 'node_modules'); +assert.ok(fs.existsSync(path.join(modules, 'jest/bin/jest.js')), 'Install Whisk dependencies before running the project Jest smoke.'); +const work = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'emulsify-jest-project-'))); +const theme = path.join(work, 'generated-theme'); +const results = path.join(work, 'results'); +const resultFile = path.join(results, 'tests.json'); +const component = path.join(theme, 'src/components/ci-jest'); +const sourceFile = path.join(component, 'jest-value.js'); +const testFile = path.join(component, 'jest-value.test.js'); +const untestedFile = path.join(component, 'jest-untested.mjs'); +const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm'; + +function run(script = 'test', extra = []) { + fs.rmSync(resultFile, { force: true }); + const result = spawnSync(npm, ['run', script, '--', '--runInBand', '--json', `--outputFile=${resultFile}`, `--coverageDirectory=${results}`, ...extra], { + cwd: theme, + encoding: 'utf8', + env: { ...process.env, CI: '1', FORCE_COLOR: '0' }, + timeout: 60000, + }); + assert.ifError(result.error); + assert.equal(result.signal, null, `Jest exited on signal ${result.signal}.`); + return { + status: result.status, + output: result.stdout + result.stderr, + report: fs.existsSync(resultFile) ? JSON.parse(fs.readFileSync(resultFile, 'utf8')) : null, + }; +} + +try { + const excluded = new Set(['.git', 'node_modules', 'dist', '.out', '.coverage', '.cache']); + fs.cpSync(starter, theme, { recursive: true, filter: (file) => !excluded.has(path.basename(file)) }); + const projectPath = path.join(theme, 'project.emulsify.json'); + const project = JSON.parse(fs.readFileSync(projectPath, 'utf8')); + Object.assign(project.project, { name: 'Project Jest Fixture', machineName: 'project-jest-fixture' }); + fs.writeFileSync(projectPath, JSON.stringify(project)); + const generation = spawnSync(process.execPath, [path.join(theme, '.cli/init.js')], { cwd: theme, encoding: 'utf8' }); + assert.equal(generation.status, 0, generation.stdout + generation.stderr); + fs.symlinkSync(modules, path.join(theme, 'node_modules'), 'junction'); + fs.mkdirSync(results); + fs.cpSync(path.join(repoRoot, '.github/fixtures/whisk-a11y/ci-readiness'), component, { recursive: true }); + + let result = run(); + assert.equal(result.status, 0, result.output); + assert.ok(result.report.testResults.some((entry) => entry.name === testFile && entry.status === 'passed'), 'Jest must execute the colocated project ESM test.'); + const coverage = JSON.parse(fs.readFileSync(path.join(results, 'coverage-final.json'), 'utf8')); + assert.ok(coverage[sourceFile], 'Coverage must include executed project source.'); + assert.ok(Object.values(coverage[sourceFile].s).some((count) => count > 0), 'Executed source must have covered statements.'); + assert.ok(coverage[untestedFile], 'Coverage must include untested project source.'); + assert.ok(Object.values(coverage[untestedFile].s).every((count) => count === 0), 'Untested project statements must remain uncovered.'); + assert.ok(!Object.keys(coverage).some((file) => /\/(?:node_modules|config)\/|\.(?:test|stories)\./.test(file)), 'Coverage must exclude dependencies, configuration, tests, and stories.'); + + result = run('twatch', ['--watch=false']); + assert.equal(result.status, 0, result.output); + assert.ok(result.report.testResults.some((entry) => entry.name === testFile && entry.status === 'passed'), 'The watch wrapper must also execute native ESM.'); + + const passing = fs.readFileSync(testFile, 'utf8'); + const failing = passing.replace('toBe(42)', 'toBe(0)'); + assert.notEqual(failing, passing, 'The deliberate failure must change the fixture assertion.'); + fs.writeFileSync(testFile, failing); + result = run(); + assert.notEqual(result.status, 0, 'A failing project test must fail npm test.'); + assert.ok(result.report.testResults.some((entry) => entry.name === testFile && entry.status === 'failed'), 'The deliberate failure must come from the discovered project test.'); + + fs.rmSync(component, { recursive: true, force: true }); + result = run(); + assert.notEqual(result.status, 0, 'An empty generated project must fail npm test.'); + assert.match(result.output, /No tests found/, 'The empty project must fail because no tests exist.'); + console.log('PASS Generated project Jest ESM, coverage, watch wrapper, failure propagation, and empty-test handling.'); +} finally { + fs.rmSync(work, { recursive: true, force: true }); +} diff --git a/.github/scripts/lint-wrappers.test.cjs b/.github/scripts/lint-wrappers.test.cjs new file mode 100644 index 0000000..f32f861 --- /dev/null +++ b/.github/scripts/lint-wrappers.test.cjs @@ -0,0 +1,34 @@ +#!/usr/bin/env node + +const assert = require('node:assert/strict'); +const { spawnSync } = require('node:child_process'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { test } = require('node:test'); + +const starterPackage = require('../../whisk/package.json'); +const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm'; + +for (const [wrapper, checks] of Object.entries({ + lint: ['lint-js', 'lint-styles'], + 'lint-fix': ['lint-js', 'lint-styles'], + format: ['lint-fix', 'prettier-fix'], +})) { + for (const failures of [[], [0], [1], [0, 1]]) { + test(`${wrapper} runs every check and propagates failures ${JSON.stringify(failures)}`, (t) => { + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'emulsify-lint-')); + t.after(() => fs.rmSync(cwd, { recursive: true, force: true })); + const scripts = { [wrapper]: starterPackage.scripts[wrapper] }; + fs.writeFileSync(path.join(cwd, 'check.cjs'), 'require("node:fs").appendFileSync("ran", process.argv[2] + "\\n"); process.exit(Number(process.argv[3]));\n'); + checks.forEach((check, index) => { + scripts[check] = `node check.cjs ${check} ${failures.includes(index) ? 23 + index : 0}`; + }); + fs.writeFileSync(path.join(cwd, 'package.json'), JSON.stringify({ scripts })); + const result = spawnSync(npm, ['run', wrapper], { cwd, encoding: 'utf8', timeout: 30000 }); + assert.ifError(result.error); + assert.equal(result.status === 0, failures.length === 0, result.stdout + result.stderr); + assert.deepEqual(fs.readFileSync(path.join(cwd, 'ran'), 'utf8').trim().split('\n'), checks); + }); + } +} diff --git a/.github/scripts/pr-validation.cjs b/.github/scripts/pr-validation.cjs index 7f05bdd..ead9407 100644 --- a/.github/scripts/pr-validation.cjs +++ b/.github/scripts/pr-validation.cjs @@ -47,4 +47,7 @@ run('Run fallback navigation smoke test', 'npm', ['run', 'smoke:navigation']); run('Run Twig switch smoke test', 'npm', ['run', 'smoke:twig-switch']); run('Run Twig project namespace smoke test', 'npm', ['run', 'smoke:twig-project-namespace']); run('Install Whisk dependencies', 'npm', ['run', 'whisk:install']); +run('Verify lint and format failure propagation', 'npm', ['run', 'test:lint-wrappers']); +run('Run generated project Jest smoke test', 'npm', ['run', 'smoke:jest-project']); +run('Run accessibility runner tests', 'npm', ['run', 'test:a11y-runner']); run('Run WordPress starter init smoke test', 'npm', ['run', 'smoke:starter-init']); diff --git a/.github/scripts/release-check.cjs b/.github/scripts/release-check.cjs index 930b77f..623fb73 100644 --- a/.github/scripts/release-check.cjs +++ b/.github/scripts/release-check.cjs @@ -405,6 +405,9 @@ function runStaticChecks() { '.github/scripts/editor-policy-smoke.php', '.github/scripts/pattern-registry-smoke.php', '.github/scripts/wordpress-starter-init-smoke.cjs', + '.github/scripts/lint-wrappers.test.cjs', + '.github/scripts/jest-project-smoke.cjs', + '.github/scripts/a11y-runner.test.cjs', '.github/scripts/theme-filters-smoke.php', '.github/scripts/twig-autoescape-smoke.php', '.github/scripts/twig-project-namespace-smoke.php', @@ -451,6 +454,9 @@ function runStaticChecks() { ensure(rootPackage.scripts['pr:check'] === 'node .github/scripts/pr-validation.cjs', 'package.json should expose npm run pr:check.'); ensure(rootPackage.scripts['release:check'] === 'node .github/scripts/release-check.cjs', 'package.json should expose npm run release:check.'); ensure(rootPackage.scripts['docs:check-commands'] === 'node .github/scripts/docs-command-check.cjs', 'package.json should expose npm run docs:check-commands.'); + ensure(rootPackage.scripts['test:lint-wrappers'] === 'node --test .github/scripts/lint-wrappers.test.cjs', 'package.json should expose the lint wrapper regression tests.'); + ensure(rootPackage.scripts['smoke:jest-project'] === 'node .github/scripts/jest-project-smoke.cjs', 'package.json should expose generated project ESM/coverage smoke coverage.'); + ensure(rootPackage.scripts['test:a11y-runner'] === 'node --test .github/scripts/a11y-runner.test.cjs', 'package.json should expose the Core accessibility wrapper tests.'); ensure(rootPackage.scripts['build:dist'] === 'bash scripts/build-dist.sh', 'package.json should expose npm run build:dist.'); ensure(rootPackage.scripts['lint:php'].includes('vendor/bin/phpcs -q'), 'package.json lint:php should run PHPCS.'); ensure(rootPackage.scripts['lint:php'].includes('vendor/bin/phpstan analyse --no-progress'), 'package.json lint:php should run PHPStan.'); @@ -885,8 +891,10 @@ function runStaticChecks() { ensure(fs.existsSync(path.join(repoRoot, 'whisk/.nvmrc')), 'Whisk should use .nvmrc for Node version tooling.'); ensure(!fs.existsSync(path.join(repoRoot, 'whisk/.nvm')), 'Whisk should not use the legacy .nvm filename.'); ensure(fs.existsSync(path.join(repoRoot, 'whisk/config/jest.config.js')), 'Whisk should provide the Jest config referenced by package scripts.'); - ensure(scripts.test === 'jest --coverage --passWithNoTests --config ./config/jest.config.js', 'whisk/package.json test script should point at the checked-in Jest config.'); + ensure(scripts.test === 'node --experimental-vm-modules node_modules/jest/bin/jest.js --coverage --config ./config/jest.config.js', 'whisk/package.json test should execute native project ESM and fail when no tests exist.'); + ensure(scripts.twatch === 'node --experimental-vm-modules node_modules/jest/bin/jest.js --no-coverage --watch --verbose --config ./config/jest.config.js', 'whisk/package.json twatch should use the same native ESM runner.'); ensure(jestConfig.includes("testEnvironment: 'node'"), 'whisk/config/jest.config.js should define a node test environment.'); + ensure(jestConfig.includes('transform: {}') && jestConfig.includes("coverageProvider: 'v8'") && jestConfig.includes('collectCoverageFrom'), 'Whisk Jest config should execute ESM directly and collect project coverage.'); ensure(initHook.includes('sanitizeLabelForSource'), 'Whisk init hook should sanitize human labels before writing source comments.'); ensure(initHook.includes("replaceThemeHeader(contents, 'Theme Name', sourceLabel)"), 'Whisk init hook should update style.css with a source-safe Theme Name.'); ensure(initHook.includes("replaceThemeHeader(contents, 'Text Domain', machineName)"), 'Whisk init hook should update style.css Text Domain.'); @@ -905,7 +913,7 @@ function runStaticChecks() { ensure(starterInitSmoke.includes("style.Template === 'emulsify'"), 'Starter init smoke should validate Template: emulsify.'); ensure(starterInitSmoke.includes('*/ echo 1; /*'), 'Starter init smoke should cover hostile source-comment labels.'); ensure(starterInitSmoke.includes('node_modules') && starterInitSmoke.includes('dist'), 'Starter init smoke should validate copied build and dependency output is absent.'); - ensure(starterInitSmoke.includes("['--prefix', 'whisk', 'run', 'test']"), 'Starter init smoke should verify the starter npm test script works after Whisk dependencies are installed.'); + ensure(starterInitSmoke.includes('No tests found'), 'Starter init smoke should verify an empty starter fails its npm test script.'); ensure(starterInitSmoke.includes("['run', 'inspect:components', '--', '--json']"), 'Starter init smoke should run the component inspector JSON command from the generated theme root.'); ensure(starterInitSmoke.includes('JSON.parse') && starterInitSmoke.includes('Array.isArray(report.components)'), 'Starter init smoke should validate the component inspector JSON report, including an empty components array.'); ensure(fs.existsSync(path.join(repoRoot, 'whisk/src/components/.gitkeep')), 'whisk/src/components should remain as an empty optional component placeholder.'); @@ -940,9 +948,10 @@ function runStaticChecks() { ensure(whiskA11yConfig.includes("storybookBuildDir: '.out'"), 'Whisk accessibility config should target the generated theme Storybook output relative to the consuming project root.'); ensure(whiskA11yConfig.includes('wait: 1000'), 'Whisk accessibility config should wait for the selected Storybook story to mount.'); ensure(whiskA11yRunner.includes('resolvePa11yStoryIds') && whiskA11yRunner.includes('storyIds.length === 0'), 'Whisk accessibility runner should discover stories and reject an empty audit.'); - ensure(whiskA11yRunner.includes('http.createServer') && whiskA11yRunner.includes('await pa11y') && whiskA11yRunner.includes('logReport'), 'Whisk accessibility runner should serve the static build over HTTP and report real Pa11y results.'); - ensure(whiskA11yRunner.includes("process.env.CI === 'true' && process.platform === 'linux'") && whiskA11yRunner.includes('pa11yOptions.chromeLaunchConfig') && whiskA11yRunner.includes("'--no-sandbox'") && whiskA11yRunner.includes("'--disable-setuid-sandbox'"), 'Whisk accessibility runner should launch Chrome without its unavailable sandbox on isolated Linux CI runners.'); - ensure(whiskA11yRunner.includes('if (hasIssues)') && whiskA11yRunner.includes('process.exitCode = 1'), 'Whisk accessibility runner should fail when Pa11y reports a non-ignored violation.'); + ensure(whiskA11yRunner.includes('resolveProjectA11yConfig') && whiskA11yRunner.includes('applyProjectA11yConfig'), 'Whisk accessibility wrapper should load the consuming project config through Core.'); + ensure(whiskA11yRunner.includes('startStorybookServer') && whiskA11yRunner.includes('lintReportAndExit') && whiskA11yRunner.includes('finally'), 'Whisk accessibility wrapper should delegate HTTP serving, bounded checks, failure reporting, and cleanup to Core.'); + ensure(!whiskA11yRunner.includes('http.createServer') && !whiskA11yRunner.includes('await pa11y'), 'Whisk should not ship a copied accessibility engine.'); + ensure(!whiskA11yRunner.includes('--no-sandbox') && !whiskA11yRunner.includes('--disable-setuid-sandbox'), 'Whisk accessibility code must keep Chrome sandboxing enabled.'); ensure(!scriptText.includes('vite-if-inputs'), 'whisk/package.json scripts should not wrap missing-input build errors.'); ensure(scripts.develop && scripts.develop.includes('npm:vite'), 'whisk/package.json develop script should run the Vite watcher.'); ensure(!scripts.webpack, 'whisk/package.json should not expose a webpack script.'); @@ -1349,6 +1358,9 @@ function runStaticChecks() { ensure(whiskA11yTemplate.includes('
    ') && whiskA11yTemplate.includes(' { - const requestUrl = new URL(request.url || '/', 'http://127.0.0.1'); - const relativePath = - decodeURIComponent(requestUrl.pathname).replace(/^\/+/, '') || 'index.html'; - const filePath = path.resolve(buildDirectory, relativePath); - const withinBuild = - filePath === buildDirectory || - filePath.startsWith(`${buildDirectory}${path.sep}`); - - if (!withinBuild) { - response.writeHead(403); - response.end('Forbidden'); - return; - } - - fs.readFile(filePath, (error, contents) => { - if (error) { - response.writeHead(404); - response.end('Not found'); - return; - } - - response.writeHead(200, { - 'Content-Type': - contentTypes[path.extname(filePath).toLowerCase()] || - 'application/octet-stream', - }); - response.end(contents); - }); -}); - -await new Promise((resolve, reject) => { - server.once('error', reject); - server.listen(0, '127.0.0.1', resolve); -}); - -const address = server.address(); -if (!address || typeof address === 'string') { - server.close(); - throw new Error('Unable to start the local Storybook accessibility server.'); -} - -const pa11yOptions = { - includeNotices: false, - includeWarnings: false, - runners: ['axe'], - ...coreA11yConfig.pa11y, - ...a11yConfig.pa11y, -}; - -if (process.env.CI === 'true' && process.platform === 'linux') { - pa11yOptions.chromeLaunchConfig = { - ...pa11yOptions.chromeLaunchConfig, - args: [ - ...(pa11yOptions.chromeLaunchConfig?.args || []), - '--no-sandbox', - '--disable-setuid-sandbox', - ], - }; -} - -let hasIssues = false; - -console.log( - `Running accessibility checks against ${storyIds.length} Storybook story.`, -); +const server = await startStorybookServer(); try { - for (const storyId of storyIds) { - const storyUrl = - `http://127.0.0.1:${address.port}/iframe.html` + - `?id=${encodeURIComponent(storyId)}&viewMode=story`; - const report = await pa11y(storyUrl, pa11yOptions); - hasIssues = logReport(report) || hasIssues; - } + await lintReportAndExit(storyIds, { + baseUrl: `${server.baseUrl}/iframe.html`, + }); } finally { - await new Promise((resolve) => server.close(resolve)); -} - -if (hasIssues) { - process.exitCode = 1; -} else { - console.log('Storybook accessibility checks passed.'); + await server.close(); } diff --git a/whisk/config/jest.config.js b/whisk/config/jest.config.js index 8f50992..a7317d6 100644 --- a/whisk/config/jest.config.js +++ b/whisk/config/jest.config.js @@ -1,10 +1,17 @@ export default { - // Jest defaults rootDir to the config file's directory. Without this the - // testMatch globs below would resolve against `config/` and silently match - // nothing under `--passWithNoTests`. + // Resolve project tests and coverage from the child-theme root. rootDir: '..', coverageDirectory: '/.coverage', testEnvironment: 'node', + // Keep native ESM imports instead of converting them to CommonJS. + transform: {}, + coverageProvider: 'v8', + collectCoverageFrom: [ + '**/*.{js,mjs,cjs,jsx}', + '!**/{node_modules,config,dist,.out,.coverage}/**', + '!**/*.{test,spec,stories}.{js,mjs,cjs,jsx}', + '!**/__tests__/**', + ], testMatch: [ '/src/**/*.test.{js,cjs,mjs}', '/components/**/*.test.{js,cjs,mjs}', diff --git a/whisk/docs/development.md b/whisk/docs/development.md index d9758a3..2401a8f 100644 --- a/whisk/docs/development.md +++ b/whisk/docs/development.md @@ -89,7 +89,17 @@ npm run test npm run a11y ``` -`npm run test` succeeds when no tests have been added yet. The accessibility check builds Storybook first, so it takes longer than lint or unit tests. +`npm run test` executes project ESM tests and collects coverage from project +source, including untested files. It fails when no tests have been added yet; +add tests alongside source under `src/`, `components/`, or `config/`. +`npm run twatch` has the same discovery behavior in watch mode. + +The accessibility check builds Storybook first, then delegates to Core's +bounded runner while rejecting an empty story list. Project options in +`config/emulsify-core/a11y.config.js`, including `pa11y.wait`, are honored. +Chrome's sandbox stays enabled; set `PUPPETEER_EXECUTABLE_PATH` to an installed +Chrome when using a managed browser. The optional `@emulsify/core/a11y/wcag22` +preset can be adopted in this config, preserving any existing project options. For machine-readable project audits, silence npm's command banner and keep the documentation footer on stderr: diff --git a/whisk/package.json b/whisk/package.json index cc8bf0c..2058b6e 100644 --- a/whisk/package.json +++ b/whisk/package.json @@ -24,10 +24,10 @@ "coverage": "npm run test && open-cli .coverage/lcov-report/index.html", "develop": "npm run ensure-dist && concurrently --raw --no-shell npm:vite npm:storybook", "ensure-dist": "mkdir -p dist", - "format": "npm run lint-fix; npm run prettier-fix", + "format": "sh -c 'status=0; npm run lint-fix || status=$?; npm run prettier-fix || status=$?; exit $status'", "inspect:components": "emulsify-inspect-components", - "lint": "npm run lint-js; npm run lint-styles", - "lint-fix": "npm run lint-js -- --fix; npm run lint-styles -- --fix", + "lint": "sh -c 'status=0; npm run lint-js || status=$?; npm run lint-styles || status=$?; exit $status'", + "lint-fix": "sh -c 'status=0; npm run lint-js -- --fix || status=$?; npm run lint-styles -- --fix || status=$?; exit $status'", "lint-js": "eslint --config config/emulsify-core/eslint.config.js --no-error-on-unmatched-pattern ./config ./src ./components", "lint-staged": "lint-staged", "lint-styles": "stylelint --config config/emulsify-core/stylelintrc.config.json --allow-empty-input './src/**/*.scss' './components/**/*.scss'", @@ -35,8 +35,8 @@ "prettier-fix": "prettier --config config/emulsify-core/prettierrc.config.json --ignore-path config/emulsify-core/.prettierignore --write --ignore-unknown --no-error-on-unmatched-pattern './config/**/*.{js,cjs,mjs,json,yml,scss,md,html}' './src/**/*.{js,cjs,mjs,json,yml,scss,md,html}' './components/**/*.{js,cjs,mjs,json,yml,scss,md,html}'", "storybook": "NODE_OPTIONS=--no-deprecation storybook dev -c node_modules/@emulsify/core/.storybook --ci -p 6006", "storybook-build": "npm run build && storybook build -c node_modules/@emulsify/core/.storybook -o .out", - "test": "jest --coverage --passWithNoTests --config ./config/jest.config.js", - "twatch": "jest --no-coverage --watch --verbose --passWithNoTests --config ./config/jest.config.js", + "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js --coverage --config ./config/jest.config.js", + "twatch": "node --experimental-vm-modules node_modules/jest/bin/jest.js --no-coverage --watch --verbose --config ./config/jest.config.js", "vite": "vite build --watch --config node_modules/@emulsify/core/config/vite/vite.config.js" }, "dependencies": { From 28db7c3f58387e08176664163fdbf92ab515d572 Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Thu, 22 Oct 2026 22:10:10 -0500 Subject: [PATCH 011/100] ci: validate Whisk tests and sandboxed runner Chrome --- .github/scripts/release-check.cjs | 7 ++++-- .github/workflows/theme-readiness.yml | 33 ++++++++++++++++++++------- docs/release-notes-next.md | 3 +++ 3 files changed, 33 insertions(+), 10 deletions(-) diff --git a/.github/scripts/release-check.cjs b/.github/scripts/release-check.cjs index 623fb73..ef5504e 100644 --- a/.github/scripts/release-check.cjs +++ b/.github/scripts/release-check.cjs @@ -1349,8 +1349,11 @@ function runStaticChecks() { ensure(extendedWhiskJob.includes("github.event_name == 'schedule'"), 'Whisk accessibility job should run on the schedule.'); ensure(extendedWhiskJob.includes("(github.event_name == 'workflow_dispatch' && inputs.extended_checks)"), 'Whisk accessibility job should honor its manual input.'); ensure(extendedWhiskJob.includes('cp -R .github/fixtures/whisk-a11y/. whisk/src/components/'), 'Whisk accessibility job should seed the CI-only component fixture.'); - ensure(extendedWhiskJob.includes('id: setup-chrome') && extendedWhiskJob.includes('uses: browser-actions/setup-chrome@v2'), 'Whisk accessibility job should install Chrome explicitly.'); - ensure(extendedWhiskJob.includes('PUPPETEER_EXECUTABLE_PATH: ${{ steps.setup-chrome.outputs.chrome-path }}'), 'Whisk accessibility job should use the Chrome path provided by setup-chrome.'); + ensure(extendedWhiskJob.includes('Select and verify runner Chrome') && extendedWhiskJob.includes('command -v google-chrome'), 'Whisk accessibility job should use the runner-installed Chrome.'); + ensure(extendedWhiskJob.includes('"$chrome_path" --version') && extendedWhiskJob.includes('"$chrome_path" --headless --dump-dom about:blank') && extendedWhiskJob.includes('PUPPETEER_EXECUTABLE_PATH=%s') && extendedWhiskJob.includes('"$GITHUB_ENV"'), 'Whisk accessibility job should verify a sandboxed Chrome launch and export its path.'); + ensure(!extendedWhiskJob.includes('browser-actions/setup-chrome') && !extendedWhiskJob.includes('--no-sandbox') && !extendedWhiskJob.includes('--disable-setuid-sandbox'), 'Whisk accessibility CI must preserve the installed browser sandbox.'); + ensure(extendedWhiskJob.includes("PUPPETEER_SKIP_DOWNLOAD: 'true'"), 'Whisk CI should skip redundant Puppeteer browser downloads.'); + ensure(extendedWhiskJob.includes('npm --prefix whisk run test -- --runInBand'), 'Whisk CI should execute real seeded project tests before the accessibility audit.'); ensure(extendedWhiskJob.includes('npm --prefix whisk run a11y'), 'Whisk accessibility job should build Storybook and run the real audit.'); ensure(!extendedWhiskJob.includes('continue-on-error: true'), 'Whisk accessibility violations should fail the job.'); ensure(whiskA11yEntry.includes('ciReadinessFixture'), 'CI-only Whisk fixture should provide a Vite entry.'); diff --git a/.github/workflows/theme-readiness.yml b/.github/workflows/theme-readiness.yml index 5238da4..d42135c 100644 --- a/.github/workflows/theme-readiness.yml +++ b/.github/workflows/theme-readiness.yml @@ -24,7 +24,7 @@ on: type: boolean default: true extended_checks: - description: Run the CI-seeded Whisk Storybook build and accessibility audit. + description: Run CI-seeded Whisk tests, Storybook build, and accessibility audit. required: false type: boolean default: false @@ -261,26 +261,43 @@ jobs: node-version-file: .nvmrc cache: npm + - name: Select and verify runner Chrome + timeout-minutes: 1 + run: | + # Ubuntu's AppArmor profile permits the installed Google Chrome. + # Verify it launches with its sandbox before Puppeteer runs. + chrome_path="$(command -v google-chrome)" + "$chrome_path" --version + "$chrome_path" --headless --dump-dom about:blank + printf 'PUPPETEER_EXECUTABLE_PATH=%s\n' "$chrome_path" >> "$GITHUB_ENV" + - name: Clean npm install run: npm ci --ignore-scripts - name: Install Whisk dependencies + env: + PUPPETEER_SKIP_DOWNLOAD: 'true' run: npm run whisk:install - name: Seed CI-only Whisk component run: cp -R .github/fixtures/whisk-a11y/. whisk/src/components/ - - name: Setup Chrome - id: setup-chrome - uses: browser-actions/setup-chrome@v2 - with: - chrome-version: stable + - name: Run CI-seeded Whisk frontend tests + run: npm --prefix whisk run test -- --runInBand - name: Build Storybook and run a11y - env: - PUPPETEER_EXECUTABLE_PATH: ${{ steps.setup-chrome.outputs.chrome-path }} run: npm --prefix whisk run a11y + - name: Upload Whisk coverage + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v4 + with: + name: extended-whisk-coverage + path: whisk/.coverage/ + include-hidden-files: true + if-no-files-found: ignore + retention-days: 7 + - name: Upload Storybook diagnostics if: failure() uses: actions/upload-artifact@v4 diff --git a/docs/release-notes-next.md b/docs/release-notes-next.md index 97ed00c..0296475 100644 --- a/docs/release-notes-next.md +++ b/docs/release-notes-next.md @@ -106,6 +106,9 @@ Title: `2.1.0` ### Internal +- Extended CI verifies the runner's installed Chrome with its sandbox enabled, + runs real seeded project tests before accessibility checks, and retains + coverage diagnostics. - Added `npm run test:generated-theme` and `npm run smoke:generation-parity`. - Added release checks for generated documentation sections, the documentation token list across both generators, and the generated child theme contract. From 5ea0d6b1001151eb83e1a43d3496bcd505be2643 Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Thu, 22 Oct 2026 22:18:59 -0500 Subject: [PATCH 012/100] test(templates): capture parent layout output before extension blocks --- .github/fixtures/template-extension/README.md | 31 +++ .../content-no-title-menu-empty.html | 59 ++++++ .../content-no-title-menu-populated.html | 59 ++++++ .../content-no-title-no-menu-empty.html | 35 ++++ .../content-no-title-no-menu-populated.html | 35 ++++ .../content-title-menu-empty.html | 62 ++++++ .../content-title-menu-populated.html | 62 ++++++ .../content-title-no-menu-empty.html | 38 ++++ .../content-title-no-menu-populated.html | 38 ++++ .../template-extension/page-no-post.html | 60 ++++++ .../template-extension/page-post.html | 68 ++++++ .github/scripts/template-extension-smoke.php | 197 ++++++++++++++++++ 12 files changed, 744 insertions(+) create mode 100644 .github/fixtures/template-extension/README.md create mode 100644 .github/fixtures/template-extension/content-no-title-menu-empty.html create mode 100644 .github/fixtures/template-extension/content-no-title-menu-populated.html create mode 100644 .github/fixtures/template-extension/content-no-title-no-menu-empty.html create mode 100644 .github/fixtures/template-extension/content-no-title-no-menu-populated.html create mode 100644 .github/fixtures/template-extension/content-title-menu-empty.html create mode 100644 .github/fixtures/template-extension/content-title-menu-populated.html create mode 100644 .github/fixtures/template-extension/content-title-no-menu-empty.html create mode 100644 .github/fixtures/template-extension/content-title-no-menu-populated.html create mode 100644 .github/fixtures/template-extension/page-no-post.html create mode 100644 .github/fixtures/template-extension/page-post.html create mode 100644 .github/scripts/template-extension-smoke.php diff --git a/.github/fixtures/template-extension/README.md b/.github/fixtures/template-extension/README.md new file mode 100644 index 0000000..b103620 --- /dev/null +++ b/.github/fixtures/template-extension/README.md @@ -0,0 +1,31 @@ +# Parent template output goldens + +These HTML files capture the parent templates at commit +`28db7c3f58387e08176664163fdbf92ab515d572`, before the new extension blocks. +Every byte, including whitespace, is compared without output normalization. + +The harness uses the parent's real Twig namespace registration, autoescaping, +attribute/BEM helpers, and head/menu/footer partials. WordPress functions and +actions emit deterministic hook markers. Only the literal date input `now` is +frozen at `2026-10-22T12:00:00Z`; formatting still delegates to Twig's real date +formatter in UTC, keeping the footer's year stable. + +The eight `content-*` files cover every combination of title, menu, and +populated/empty content. Empty cases render the real base directly and compare +it with an empty ArrayLoader child content block at runtime. Populated cases +use that child template to supply the real base's content block. The two +`page-*` files render the real parent page fallback with a post and without one. + +Verify without rewriting fixtures: + +```sh +php .github/scripts/template-extension-smoke.php +``` + +Capture intentionally, after reviewing the rendered-output change: + +```sh +php .github/scripts/template-extension-smoke.php --update-goldens +``` + +Updating goldens is an explicit review step. Do not use the capture option in CI. diff --git a/.github/fixtures/template-extension/content-no-title-menu-empty.html b/.github/fixtures/template-extension/content-no-title-menu-empty.html new file mode 100644 index 0000000..f1a3baa --- /dev/null +++ b/.github/fixtures/template-extension/content-no-title-menu-empty.html @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + +
    + +
    + + +
    + +
    + + + + diff --git a/.github/fixtures/template-extension/content-no-title-menu-populated.html b/.github/fixtures/template-extension/content-no-title-menu-populated.html new file mode 100644 index 0000000..a6f35d5 --- /dev/null +++ b/.github/fixtures/template-extension/content-no-title-menu-populated.html @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + +
    + +

    Content & fixture

    + + +
    + +
    + + + + diff --git a/.github/fixtures/template-extension/content-no-title-no-menu-empty.html b/.github/fixtures/template-extension/content-no-title-no-menu-empty.html new file mode 100644 index 0000000..a246245 --- /dev/null +++ b/.github/fixtures/template-extension/content-no-title-no-menu-empty.html @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + +
    + +
    + + +
    + +
    + + + + diff --git a/.github/fixtures/template-extension/content-no-title-no-menu-populated.html b/.github/fixtures/template-extension/content-no-title-no-menu-populated.html new file mode 100644 index 0000000..40cb9ac --- /dev/null +++ b/.github/fixtures/template-extension/content-no-title-no-menu-populated.html @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + +
    + +

    Content & fixture

    + + +
    + +
    + + + + diff --git a/.github/fixtures/template-extension/content-title-menu-empty.html b/.github/fixtures/template-extension/content-title-menu-empty.html new file mode 100644 index 0000000..5192f87 --- /dev/null +++ b/.github/fixtures/template-extension/content-title-menu-empty.html @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + +
    + + +
    + + +
    + +
    + + + + diff --git a/.github/fixtures/template-extension/content-title-menu-populated.html b/.github/fixtures/template-extension/content-title-menu-populated.html new file mode 100644 index 0000000..7d5c97c --- /dev/null +++ b/.github/fixtures/template-extension/content-title-menu-populated.html @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + +
    + + +

    Content & fixture

    + + +
    + +
    + + + + diff --git a/.github/fixtures/template-extension/content-title-no-menu-empty.html b/.github/fixtures/template-extension/content-title-no-menu-empty.html new file mode 100644 index 0000000..7147296 --- /dev/null +++ b/.github/fixtures/template-extension/content-title-no-menu-empty.html @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + +
    + + +
    + + +
    + +
    + + + + diff --git a/.github/fixtures/template-extension/content-title-no-menu-populated.html b/.github/fixtures/template-extension/content-title-no-menu-populated.html new file mode 100644 index 0000000..b2260d3 --- /dev/null +++ b/.github/fixtures/template-extension/content-title-no-menu-populated.html @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + +
    + + +

    Content & fixture

    + + +
    + +
    + + + + diff --git a/.github/fixtures/template-extension/page-no-post.html b/.github/fixtures/template-extension/page-no-post.html new file mode 100644 index 0000000..2064655 --- /dev/null +++ b/.github/fixtures/template-extension/page-no-post.html @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + +
    + +

    No page content was found.

    +
    + + +
    + +
    + + + + diff --git a/.github/fixtures/template-extension/page-post.html b/.github/fixtures/template-extension/page-post.html new file mode 100644 index 0000000..4308d14 --- /dev/null +++ b/.github/fixtures/template-extension/page-post.html @@ -0,0 +1,68 @@ + + + + + + + + + + + + + + +
    + +
    +
    +

    Post <title> & fixture

    +
    + +
    +

    Content & fixture

    +
    +
    +
    + + +
    + +
    + + + + diff --git a/.github/scripts/template-extension-smoke.php b/.github/scripts/template-extension-smoke.php new file mode 100644 index 0000000..ad1f1b5 --- /dev/null +++ b/.github/scripts/template-extension-smoke.php @@ -0,0 +1,197 @@ +'; +} + +function wp_head(): void { + $GLOBALS['emulsify_extension_hooks'][] = 'wp_head'; + echo ''; +} + +function wp_body_open(): void { + $GLOBALS['emulsify_extension_hooks'][] = 'wp_body_open'; + echo ''; +} + +function wp_footer(): void { + $GLOBALS['emulsify_extension_hooks'][] = 'wp_footer'; + echo ''; +} + +/** + * Builds deterministic data with fresh attribute helpers for every render. + * + * @param bool $has_title Whether the document has a title. + * @param bool $has_menu Whether primary menu items exist. + * @param bool $has_content Whether the fixture content exists. + * @return array Template context. + */ +function emulsify_extension_context( bool $has_title, bool $has_menu, bool $has_content ): array { + $items = array( + array( + 'title' => 'Current & fixture', + 'link' => '/current/?first=1&second=2', + 'classes' => array( 'fixture-menu-item' ), + 'current' => true, + 'children' => array( + array( 'title' => 'Nested & fixture', 'link' => '/nested/', 'target' => '_blank' ), + ), + ), + array( 'title' => 'Other & fixture', 'link' => '/other/' ), + ); + $context = array( + 'site' => array( + 'url' => 'https://example.test/?first=1&second=2', + 'name' => 'Fixture & WordPress', + 'charset' => 'UTF-8', + 'language_attributes' => new \Twig\Markup( 'lang="en" dir="ltr"', 'UTF-8' ), + ), + 'body_class' => 'fixture-body route-page', + 'menu' => $has_menu ? array( 'get_items' => $items ) : null, + 'fixture_content' => $has_content ? '

    Content & fixture

    ' : '', + ); + + if ( $has_title ) { + $context['title'] = 'Page & fixture'; + } + + return $context; +} + +/** + * Captures explicitly or compares every output byte against a committed file. + * + * @param string $name Golden name. + * @param string $output Rendered output. + * @param bool $capture Whether to update the golden deliberately. + * @return void + */ +function emulsify_extension_golden( string $name, string $output, bool $capture ): void { + $file = __DIR__ . '/../fixtures/template-extension/' . $name . '.html'; + + if ( $capture ) { + if ( false === file_put_contents( $file, $output ) ) { + throw new RuntimeException( 'Could not capture ' . $file ); + } + } elseif ( ! is_file( $file ) || file_get_contents( $file ) !== $output ) { + throw new RuntimeException( $name . ': rendered bytes differ from the pre-extension golden file.' ); + } + + echo ( $capture ? 'CAPTURE ' : 'PASS ' ) . $name . ' (' . strlen( $output ) . " bytes)\n"; +} + +$capture = array_slice( $argv, 1 ) === array( '--update-goldens' ); + +if ( count( $argv ) > 1 && ! $capture ) { + fwrite( STDERR, "Only --update-goldens is supported; ordinary runs never rewrite fixtures.\n" ); + exit( 1 ); +} + +try { + if ( ! mkdir( get_stylesheet_directory() . '/templates', 0777, true ) ) { + throw new RuntimeException( 'Could not create the disposable child theme.' ); + } + $integration = new Twig(); + $filesystem = $integration->loader_paths( new \Twig\Loader\FilesystemLoader() ); + $fixtures = new \Twig\Loader\ArrayLoader( + array( + 'content-fixture.twig' => '{% extends "@emulsify-tpl/layouts/base.twig" %}{% block content %}{{ fixture_content|raw }}{% endblock %}', + ) + ); + $environment = new \Twig\Environment( + new \Twig\Loader\ChainLoader( array( $fixtures, $filesystem ) ), + $integration->environment_options( array( 'cache' => false ) ) + ); + + foreach ( $integration->functions( array() ) as $name => $definition ) { + $environment->addFunction( new \Twig\TwigFunction( $name, $definition['callable'], $definition['options'] ?? array() ) ); + } + $environment->addFunction( new \Twig\TwigFunction( 'function', array( new \Timber\Twig(), 'exec_function' ) ) ); + $environment->addFunction( new \Twig\TwigFunction( 'action', 'do_action' ) ); + $integration->extensions( $environment ); + $core = $environment->getExtension( \Twig\Extension\CoreExtension::class ); + $core->setTimezone( 'UTC' ); + $environment->addFilter( + new \Twig\TwigFilter( + 'date', + static function ( $date, $format = null, $timezone = null ) use ( $core ): string { + return $core->formatDate( 'now' === $date ? new DateTimeImmutable( '2026-10-22T12:00:00Z' ) : $date, $format, $timezone ); + } + ) + ); + + foreach ( array( false, true ) as $has_title ) { + foreach ( array( false, true ) as $has_menu ) { + $suffix = ( $has_title ? 'title' : 'no-title' ) . '-' . ( $has_menu ? 'menu' : 'no-menu' ); + + foreach ( array( false, true ) as $has_content ) { + $template = $has_content ? 'content-fixture.twig' : '@emulsify-tpl/layouts/base.twig'; + $output = $environment->render( $template, emulsify_extension_context( $has_title, $has_menu, $has_content ) ); + if ( ! $has_content && $environment->render( 'content-fixture.twig', emulsify_extension_context( $has_title, $has_menu, false ) ) !== $output ) { + throw new RuntimeException( 'An empty child content block must preserve the direct base output.' ); + } + emulsify_extension_golden( 'content-' . $suffix . '-' . ( $has_content ? 'populated' : 'empty' ), $output, $capture ); + } + } + } + + foreach ( array( false, true ) as $has_post ) { + $context = emulsify_extension_context( false, true, $has_post ); + $context['post'] = $has_post ? array( 'id' => 42, 'type' => 'page', 'title' => 'Post <title> & fixture', 'content' => $context['fixture_content'] ) : null; + emulsify_extension_golden( $has_post ? 'page-post' : 'page-no-post', $environment->render( '@emulsify-tpl/page.twig', $context ), $capture ); + } +} catch ( Throwable $throwable ) { + fwrite( STDERR, $throwable->getMessage() . "\n" ); + exit( 1 ); +} finally { + if ( is_dir( get_stylesheet_directory() . '/templates' ) ) { + rmdir( get_stylesheet_directory() . '/templates' ); + } + if ( is_dir( get_stylesheet_directory() ) ) { + rmdir( get_stylesheet_directory() ); + } +} From 9056a3d7cf9e1798a4665b9c3bba746092f22524 Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Thu, 22 Oct 2026 22:22:42 -0500 Subject: [PATCH 013/100] feat(templates): add stable parent layout extension blocks --- .github/scripts/pr-validation.cjs | 1 + .github/scripts/template-extension-smoke.php | 65 ++++++++++++++++++++ package.json | 1 + templates/layouts/base.twig | 6 ++ 4 files changed, 73 insertions(+) diff --git a/.github/scripts/pr-validation.cjs b/.github/scripts/pr-validation.cjs index ead9407..869018b 100644 --- a/.github/scripts/pr-validation.cjs +++ b/.github/scripts/pr-validation.cjs @@ -43,6 +43,7 @@ run('Run editor policy smoke test', 'npm', ['run', 'smoke:editor-policy']); run('Run pattern registry smoke test', 'npm', ['run', 'smoke:patterns']); run('Run parent theme filter smoke test', 'npm', ['run', 'smoke:theme-filters']); run('Run Twig autoescape smoke test', 'npm', ['run', 'smoke:twig-autoescape']); +run('Run parent template extension smoke test', 'npm', ['run', 'smoke:template-extension']); run('Run fallback navigation smoke test', 'npm', ['run', 'smoke:navigation']); run('Run Twig switch smoke test', 'npm', ['run', 'smoke:twig-switch']); run('Run Twig project namespace smoke test', 'npm', ['run', 'smoke:twig-project-namespace']); diff --git a/.github/scripts/template-extension-smoke.php b/.github/scripts/template-extension-smoke.php index ad1f1b5..795f1cf 100644 --- a/.github/scripts/template-extension-smoke.php +++ b/.github/scripts/template-extension-smoke.php @@ -124,6 +124,20 @@ function emulsify_extension_golden( string $name, string $output, bool $capture echo ( $capture ? 'CAPTURE ' : 'PASS ' ) . $name . ' (' . strlen( $output ) . " bytes)\n"; } +/** + * Collects independent extension failures in one run. + * + * @param bool $condition Assertion result. + * @param string $message Failure explanation. + * @param array $errors Collected failures. + * @return void + */ +function emulsify_extension_expect( bool $condition, string $message, array &$errors ): void { + if ( ! $condition ) { + $errors[] = $message; + } +} + $capture = array_slice( $argv, 1 ) === array( '--update-goldens' ); if ( count( $argv ) > 1 && ! $capture ) { @@ -184,6 +198,57 @@ static function ( $date, $format = null, $timezone = null ) use ( $core ): strin $context['post'] = $has_post ? array( 'id' => 42, 'type' => 'page', 'title' => 'Post <title> & fixture', 'content' => $context['fixture_content'] ) : null; emulsify_extension_golden( $has_post ? 'page-post' : 'page-no-post', $environment->render( '@emulsify-tpl/page.twig', $context ), $capture ); } + + $errors = array(); + $page_expected = file_get_contents( __DIR__ . '/../fixtures/template-extension/page-post.html' ); + foreach ( array( 'head', 'header', 'content', 'footer', 'site_header', 'main', 'page_title' ) as $block ) { + $name = 'child-page-' . $block . '.twig'; + $marker = '<!-- child:' . $block . ' -->'; + $fixtures->setTemplate( $name, '{% extends "@emulsify-tpl/page.twig" %}{% block ' . $block . ' %}' . $marker . '{{ parent() }}{% endblock %}' ); + $context = emulsify_extension_context( false, true, true ); + $context['post'] = array( 'id' => 42, 'type' => 'page', 'title' => 'Post <title> & fixture', 'content' => $context['fixture_content'] ); + $output = $environment->render( $name, $context ); + emulsify_extension_expect( 1 === substr_count( $output, $marker ), 'A child page must override the ' . $block . ' block exactly once.', $errors ); + emulsify_extension_expect( str_replace( $marker, '', $output ) === $page_expected, 'The ' . $block . ' override with parent() must preserve every surrounding parent-page byte.', $errors ); + } + + $replacement_cases = array( + 'site_header' => array( 'absent' => array( '<header class="site-header"', '<nav ' ), 'present' => array( '<main class="site-main"', '<header class="page-header"', '<p>Content & fixture</p>', '<footer ' ) ), + 'main' => array( 'absent' => array( '<main ', '<header class="page-header"', '<p>Content & fixture</p>' ), 'present' => array( '<header class="site-header"', '<footer ', 'href="#content"' ) ), + 'page_title' => array( 'absent' => array( '<header class="page-header"' ), 'present' => array( '<header class="site-header"', '<main class="site-main"', '<p>Content & fixture</p>', '<footer ' ) ), + 'footer' => array( 'absent' => array( '<footer ' ), 'present' => array( '<header class="site-header"', '<main class="site-main"', '<p>Content & fixture</p>' ) ), + ); + $hooks_expected = array( 'get_header', 'wp_head', 'wp_body_open', 'wp_footer', 'get_footer' ); + + foreach ( $replacement_cases as $block => $fragments ) { + $name = 'replace-' . $block . '.twig'; + $marker = '<!-- replacement:' . $block . ' -->'; + $fixtures->setTemplate( $name, '{% extends "content-fixture.twig" %}{% block ' . $block . ' %}' . $marker . '{% endblock %}' ); + $GLOBALS['emulsify_extension_hooks'] = array(); + $output = $environment->render( $name, emulsify_extension_context( true, true, true ) ); + emulsify_extension_expect( 1 === substr_count( $output, $marker ), 'Replacing ' . $block . ' must render the child block.', $errors ); + foreach ( $fragments['absent'] as $fragment ) { + emulsify_extension_expect( false === strpos( $output, $fragment ), 'Replacing ' . $block . ' must remove its complete wrapper/content: ' . $fragment, $errors ); + } + foreach ( $fragments['present'] as $fragment ) { + emulsify_extension_expect( false !== strpos( $output, $fragment ), 'Replacing ' . $block . ' must preserve its surrounding markup: ' . $fragment, $errors ); + } + emulsify_extension_expect( $hooks_expected === $GLOBALS['emulsify_extension_hooks'], 'Replacing ' . $block . ' must preserve the structural WordPress hooks and their order.', $errors ); + } + + foreach ( array( 'site_header' => 'header', 'main' => 'page_title' ) as $outer => $inner ) { + $name = 'nested-' . $outer . '.twig'; + $outer_marker = '<!-- outer:' . $outer . ' -->'; + $inner_marker = '<!-- inner:' . $inner . ' -->'; + $fixtures->setTemplate( $name, '{% extends "content-fixture.twig" %}{% block ' . $outer . ' %}' . $outer_marker . '{% endblock %}{% block ' . $inner . ' %}' . $inner_marker . '{% endblock %}' ); + $output = $environment->render( $name, emulsify_extension_context( true, true, true ) ); + emulsify_extension_expect( 1 === substr_count( $output, $outer_marker ) && false === strpos( $output, $inner_marker ), $inner . ' must remain nested inside ' . $outer . ' and disappear when the enclosing block is replaced.', $errors ); + } + + if ( $errors ) { + throw new RuntimeException( implode( "\n", $errors ) ); + } + echo "PASS child page inheritance, seven block names, replacement boundaries, nested blocks, and structural WordPress hooks.\n"; } catch ( Throwable $throwable ) { fwrite( STDERR, $throwable->getMessage() . "\n" ); exit( 1 ); diff --git a/package.json b/package.json index 540c6c4..ad1e84f 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "smoke:jest-project": "node .github/scripts/jest-project-smoke.cjs", "smoke:patterns": "php .github/scripts/pattern-registry-smoke.php", "smoke:starter-init": "node .github/scripts/wordpress-starter-init-smoke.cjs", + "smoke:template-extension": "php .github/scripts/template-extension-smoke.php", "smoke:theme-filters": "php .github/scripts/theme-filters-smoke.php", "smoke:twig-autoescape": "php .github/scripts/twig-autoescape-smoke.php", "smoke:twig-switch": "php .github/scripts/twig-switch-smoke.php", diff --git a/templates/layouts/base.twig b/templates/layouts/base.twig index 6851b1b..1acb433 100644 --- a/templates/layouts/base.twig +++ b/templates/layouts/base.twig @@ -45,6 +45,7 @@ {{ function('wp_body_open') }} <a {{ add_attributes(skip_link_attributes) }}>{{ function('__', 'Skip to content', 'emulsify') }}</a> +{% block site_header %} <header {{ add_attributes(site_header_attributes) }}> <div {{ add_attributes(site_header_inner_attributes) }}> {% block header %} @@ -63,16 +64,21 @@ {% endblock %} </div> </header> +{% endblock %} +{% block main %} <main {{ add_attributes(site_main_attributes) }}> +{% block page_title %} {% if title|default(null) %} <header {{ add_attributes(page_header_attributes) }}> <h1 {{ add_attributes(page_header_title_attributes) }}>{{ title }}</h1> </header> {% endif %} +{% endblock %} {% block content %}{% endblock %} </main> +{% endblock %} {% block footer %} {% include '@templates/partials/footer.twig' %} From 6bd2cd91e821f069fdebde7901cad1d4a6e6e2a0 Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Thu, 22 Oct 2026 22:29:36 -0500 Subject: [PATCH 014/100] fix(whisk): inherit parent templates without starter overrides --- .../scripts/child-theme-generator-smoke.php | 6 +- .github/scripts/generated-theme-contract.cjs | 1 - .../scripts/generated-theme-contract.test.cjs | 39 +++++++-- .github/scripts/release-check.cjs | 38 ++++++--- .github/scripts/template-extension-smoke.php | 8 +- .github/scripts/wordpress-fixture-smoke.cjs | 85 +++++++++++++++++-- .../scripts/wordpress-starter-init-smoke.cjs | 4 +- includes/Cli/GenerateChildThemeCommand.php | 9 -- whisk/.cli/init.js | 11 --- whisk/templates/page.twig | 9 -- 10 files changed, 147 insertions(+), 63 deletions(-) delete mode 100644 whisk/templates/page.twig diff --git a/.github/scripts/child-theme-generator-smoke.php b/.github/scripts/child-theme-generator-smoke.php index 142ef2b..529a614 100644 --- a/.github/scripts/child-theme-generator-smoke.php +++ b/.github/scripts/child-theme-generator-smoke.php @@ -279,7 +279,6 @@ function emulsify_cli_smoke_lint_php( string $path ): void { $style = file_get_contents( $destination . '/style.css' ); $package = emulsify_cli_smoke_json( $destination . '/package.json' ); $project = emulsify_cli_smoke_json( $destination . '/project.emulsify.json' ); - $page = file_get_contents( $destination . '/templates/page.twig' ); $functions = file_get_contents( $destination . '/functions.php' ); $smoke_pattern = emulsify_cli_smoke_json( $destination . '/patterns/smoke-pattern.json' ); @@ -297,8 +296,7 @@ function emulsify_cli_smoke_lint_php( string $path ): void { emulsify_cli_smoke_assert( 'acme-child' === $project['project']['machineName'], 'project.emulsify.json should update machineName.' ); emulsify_cli_smoke_assert( 'emulsify-wordpress' === $project['project']['generatedFrom'], 'project.emulsify.json should identify the generated child theme source.' ); emulsify_cli_smoke_assert( emulsify_cli_smoke_expected_version() === $project['project']['generatedFromVersion'], 'project.emulsify.json should record the generated child theme source version.' ); - emulsify_cli_smoke_assert( false !== strpos( $page, 'acme-child-page' ), 'Example template should update slug class.' ); - emulsify_cli_smoke_assert( false === strpos( $page, 'whisk-page' ), 'Example template should not keep the whisk slug class.' ); + emulsify_cli_smoke_assert( ! file_exists( $destination . '/templates' ), 'Fresh generated child themes should inherit parent templates without a local templates directory.' ); emulsify_cli_smoke_assert( false !== strpos( $functions, 'Acme Theme child theme hooks.' ), 'functions.php should update visible Whisk label.' ); emulsify_cli_smoke_assert( is_file( $destination . '/src/components/.gitkeep' ), 'Generated child theme should keep the empty component source placeholder.' ); emulsify_cli_smoke_assert( is_file( $destination . '/patterns/.gitkeep' ), 'Generated child theme should keep the empty pattern placeholder.' ); @@ -515,7 +513,7 @@ function emulsify_cli_smoke_lint_php( string $path ): void { $GLOBALS['emulsify_mkdir_failure'] = static function ( string $target ): bool { $normalized = str_replace( '\\', '/', $target ); - return false !== strpos( $normalized, '/rollback-child.tmp-' ) && str_ends_with( $normalized, '/templates' ); + return false !== strpos( $normalized, '/rollback-child.tmp-' ) && str_ends_with( $normalized, '/config' ); }; try { diff --git a/.github/scripts/generated-theme-contract.cjs b/.github/scripts/generated-theme-contract.cjs index f911bd7..c89cef3 100644 --- a/.github/scripts/generated-theme-contract.cjs +++ b/.github/scripts/generated-theme-contract.cjs @@ -48,7 +48,6 @@ const REQUIRED_GENERATED_FILES = [ 'docs/development.md', 'docs/support-information.md', 'docs/upgrading.md', - 'templates/page.twig', ]; // Generation-only tooling and build output must never reach a project repo. diff --git a/.github/scripts/generated-theme-contract.test.cjs b/.github/scripts/generated-theme-contract.test.cjs index dad3ed6..3c6298a 100644 --- a/.github/scripts/generated-theme-contract.test.cjs +++ b/.github/scripts/generated-theme-contract.test.cjs @@ -115,6 +115,36 @@ test('accepts a freshly generated child theme', () => { }); }); +test('freshly generated child themes inherit parent templates without local Twig files', () => { + withGeneratedTheme((target) => { + assert.equal(fs.existsSync(path.join(target, 'templates')), false); + assert.deepEqual( + fs.readdirSync(target, { recursive: true }).filter((file) => file.endsWith('.twig')), + [], + ); + }); +}); + +test('accepts a generated child theme without a local page template', () => { + withGeneratedTheme((target) => { + fs.rmSync(path.join(target, 'templates'), { recursive: true, force: true }); + const result = validate(target); + assert.deepEqual(result.errors, [], result.format()); + }); +}); + +test('allows consumers to add an intentional page override after generation', () => { + withGeneratedTheme((target) => { + fs.mkdirSync(path.join(target, 'templates'), { recursive: true }); + fs.writeFileSync( + path.join(target, 'templates/page.twig'), + "{% extends '@emulsify-tpl/page.twig' %}\n{% block content %}<div class=\"project-page\">{{ parent() }}</div>{% endblock %}\n", + ); + const result = validate(target); + assert.deepEqual(result.errors, [], result.format()); + }); +}); + test('requires the generated documentation set', () => { withGeneratedTheme((target) => { fs.rmSync(path.join(target, 'README.md')); @@ -143,11 +173,10 @@ test('reports leftover documentation tokens', () => { test('reports a stale starter machine name', () => { withGeneratedTheme((target) => { - const templatePath = path.join(target, 'templates/page.twig'); - fs.writeFileSync( - templatePath, - fs.readFileSync(templatePath, 'utf8').replace(`${machineName}-page`, 'whisk-page'), - ); + const packagePath = path.join(target, 'package.json'); + const packageJson = readJson(packagePath); + packageJson.name = 'whisk'; + writeJson(packagePath, packageJson); const result = validate(target); assert.match(result.format(), /FAIL placeholder replacement/); diff --git a/.github/scripts/release-check.cjs b/.github/scripts/release-check.cjs index ef5504e..398486f 100644 --- a/.github/scripts/release-check.cjs +++ b/.github/scripts/release-check.cjs @@ -394,6 +394,9 @@ function runStaticChecks() { 'templates/search.twig', 'templates/single-password.twig', 'templates/single.twig', + 'docs/template-extension.md', + '.github/scripts/template-extension-smoke.php', + '.github/fixtures/template-extension/README.md', '.github/scripts/acf-local-json-smoke.php', '.github/scripts/asset-manifest-smoke.php', '.github/scripts/attribute-helper-smoke.php', @@ -426,7 +429,6 @@ function runStaticChecks() { 'whisk/config/acf-json/.gitkeep', 'whisk/patterns/.gitkeep', 'whisk/src/components/.gitkeep', - 'whisk/templates/page.twig', ]; const missingFiles = requiredFiles.filter((file) => !fs.existsSync(path.join(repoRoot, file))); ensure(missingFiles.length === 0, `Missing required files: ${missingFiles.join(', ')}.`); @@ -475,6 +477,7 @@ function runStaticChecks() { ensure(rootPackage.scripts['smoke:theme-filters'] === 'php .github/scripts/theme-filters-smoke.php', 'package.json should expose npm run smoke:theme-filters.'); ensure(rootPackage.scripts['smoke:twig-switch'] === 'php .github/scripts/twig-switch-smoke.php', 'package.json should expose npm run smoke:twig-switch.'); ensure(rootPackage.scripts['smoke:twig-project-namespace'] === 'php .github/scripts/twig-project-namespace-smoke.php', 'package.json should expose npm run smoke:twig-project-namespace.'); + ensure(rootPackage.scripts['smoke:template-extension'] === 'php .github/scripts/template-extension-smoke.php', 'package.json should expose the exact-byte template extension smoke.'); ensure(rootPackage.scripts['whisk:install'], 'package.json should expose npm run whisk:install.'); ensure(rootPackage.scripts['whisk:build'] === 'npm --prefix whisk run build', 'package.json should expose npm run whisk:build.'); ensure(!rootPackage.devDependencies['@semantic-release/changelog'], 'package.json should not declare unused @semantic-release/changelog tooling.'); @@ -971,7 +974,6 @@ function runStaticChecks() { const projectComponentLoader = readFile('includes/Twig/ProjectComponentLoader.php'); const twigNamespaceSmoke = readFile('.github/scripts/twig-project-namespace-smoke.php'); const childFunctions = readFile('whisk/functions.php'); - const childPageTemplate = readFile('whisk/templates/page.twig'); const parentTemplateFiles = listFilesRecursive('templates', (file) => file.endsWith('.twig')).sort(); const childTemplateFiles = listFilesRecursive('whisk/templates', (file) => file.endsWith('.twig')).sort(); const expectedParentFallbacks = [ @@ -984,12 +986,6 @@ function runStaticChecks() { 'templates/single-password.twig', 'templates/single.twig', ]; - const unexpectedChildTemplates = childTemplateFiles.filter((file) => file !== 'whisk/templates/page.twig'); - const duplicateChildTemplates = childTemplateFiles.filter((file) => { - const parentFile = file.replace(/^whisk\//, ''); - const parentPath = path.join(repoRoot, parentFile); - return fs.existsSync(parentPath) && readFile(file) === readFile(parentFile); - }); const childTemplatePathIndex = twigIntegration.indexOf("'path' => get_stylesheet_directory() . '/templates'"); const parentTemplatePathIndex = twigIntegration.indexOf("'path' => get_template_directory() . '/templates'"); const childComponentSrcPathIndex = twigIntegration.indexOf("'path' => get_stylesheet_directory() . '/src/components'"); @@ -1000,10 +996,8 @@ function runStaticChecks() { for (const fallback of expectedParentFallbacks) { ensure(parentTemplateFiles.includes(fallback), `${fallback} should exist as a parent fallback.`); } - ensure(unexpectedChildTemplates.length === 0, `Whisk should not duplicate parent fallback templates: ${unexpectedChildTemplates.join(', ')}.`); - ensure(duplicateChildTemplates.length === 0, `Whisk templates should not be byte-identical parent copies: ${duplicateChildTemplates.join(', ')}.`); - ensure(childPageTemplate.includes("{% extends '@emulsify-tpl/page.twig' %}"), 'whisk/templates/page.twig should extend the parent-only page fallback.'); - ensure(childPageTemplate.includes('{{ parent() }}'), 'whisk/templates/page.twig should demonstrate wrapping parent fallback output.'); + ensure(childTemplateFiles.length === 0, `Whisk ships no Twig templates; remove ${childTemplateFiles.join(', ')} and see docs/template-extension.md for intentional child overrides.`); + ensure(!fs.existsSync(path.join(repoRoot, 'whisk/templates')), 'Whisk should omit the templates directory; see docs/template-extension.md before adding project overrides.'); ensure(childTemplatePathIndex !== -1, 'Twig integration should register child @templates path.'); ensure(parentTemplatePathIndex !== -1, 'Twig integration should register parent @templates path.'); ensure(childTemplatePathIndex < parentTemplatePathIndex, 'Twig integration should register child @templates before parent @templates.'); @@ -1041,13 +1035,29 @@ function runStaticChecks() { ensure(wordpressFixtureSmoke.includes('assertGeneratedChildTheme'), 'WordPress fixture smoke should validate generated child theme metadata and copied Whisk files.'); ensure(wordpressFixtureSmoke.includes("project.project?.generatedFrom !== 'emulsify-wordpress'"), 'WordPress fixture smoke should validate generatedFrom metadata.'); ensure(wordpressFixtureSmoke.includes('project.project?.generatedFromVersion !== expectedGeneratedFromVersion'), 'WordPress fixture smoke should derive the expected generatedFromVersion from root package metadata instead of a literal.'); - ensure(wordpressFixtureSmoke.includes('${themeSlug}-page'), 'WordPress fixture smoke should prove the generated child page template renders through Timber.'); + ensure(wordpressFixtureSmoke.includes('Generated child theme should not include Twig templates by default.') && wordpressFixtureSmoke.includes('entry__content'), 'WordPress fixture smoke should prove parent page rendering without a generated child Twig override.'); + ensure(wordpressFixtureSmoke.includes('Child block extension must preserve every surrounding byte.') && wordpressFixtureSmoke.includes("{% extends '@emulsify-tpl/page.twig' %}") && wordpressFixtureSmoke.includes('finally'), 'WordPress fixture smoke should prove one-block child inheritance and restore the temporary override; see docs/template-extension.md.'); ensure(wordpressFixtureSmoke.includes('checkGeneratedAssets'), 'WordPress fixture smoke should fetch generated child theme built assets.'); ensure(wordpressFixtureSmoke.includes('runAcfDiscoveryWithoutAcf'), 'WordPress fixture smoke should check ACF/Twig discovery when ACF is absent.'); ensure(wordpressFixtureSmoke.includes('installAcfStub'), 'WordPress fixture smoke should provide a fixture-only ACF stub.'); ensure(wordpressFixtureSmoke.includes('runAcfDiscoveryWithStub'), 'WordPress fixture smoke should check ACF/Twig registration with the ACF stub.'); ensure(wordpressFixtureSmoke.includes('emulsify/smoke-native'), 'WordPress fixture smoke should check native block.json discovery and registration.'); - return 'Parent owns route fallbacks and default Twig namespaces; Whisk ships only the page override example.'; + return 'Parent owns route fallbacks and default Twig namespaces; Whisk ships no Twig templates.'; + }); + + runStaticCheck('Template extension contract', () => { + const base = readFile('templates/layouts/base.twig'); + const extensionDocs = readFile('docs/template-extension.md'); + for (const block of ['head', 'header', 'content', 'footer', 'site_header', 'main', 'page_title']) { + ensure(base.includes(`{% block ${block} %}`) && extensionDocs.includes('`' + block + '`'), `Preserve the public ${block} block and boundary documented in docs/template-extension.md.`); + } + const goldens = listFilesRecursive('.github/fixtures/template-extension', (file) => file.endsWith('.html')); + ensure(goldens.length >= 10, 'Keep the pre-extension output captures; see docs/template-extension.md.'); + ensure(readFile('.github/scripts/pr-validation.cjs').includes('smoke:template-extension'), 'PR validation should compare exact template bytes and child extension boundaries.'); + for (const file of ['README.md', 'docs/README.md', 'docs/timber-and-twig-authoring.md']) { + ensure(readFile(file).includes('template-extension.md'), `${file} should link to the public template extension contract.`); + } + return 'Seven public layout blocks retain ten golden outputs, tested child inheritance, and documented boundaries.'; }); runStaticCheck('Generated child theme documentation', () => { diff --git a/.github/scripts/template-extension-smoke.php b/.github/scripts/template-extension-smoke.php index 795f1cf..adfcfb7 100644 --- a/.github/scripts/template-extension-smoke.php +++ b/.github/scripts/template-extension-smoke.php @@ -138,7 +138,8 @@ function emulsify_extension_expect( bool $condition, string $message, array &$er } } -$capture = array_slice( $argv, 1 ) === array( '--update-goldens' ); +$capture = array_slice( $argv, 1 ) === array( '--update-goldens' ); +$exit_code = 0; if ( count( $argv ) > 1 && ! $capture ) { fwrite( STDERR, "Only --update-goldens is supported; ordinary runs never rewrite fixtures.\n" ); @@ -234,6 +235,7 @@ static function ( $date, $format = null, $timezone = null ) use ( $core ): strin emulsify_extension_expect( false !== strpos( $output, $fragment ), 'Replacing ' . $block . ' must preserve its surrounding markup: ' . $fragment, $errors ); } emulsify_extension_expect( $hooks_expected === $GLOBALS['emulsify_extension_hooks'], 'Replacing ' . $block . ' must preserve the structural WordPress hooks and their order.', $errors ); + emulsify_extension_expect( 1 === substr_count( $output, 'href="#content"' ), 'Replacing ' . $block . ' must preserve the skip link outside the block.', $errors ); } foreach ( array( 'site_header' => 'header', 'main' => 'page_title' ) as $outer => $inner ) { @@ -251,7 +253,7 @@ static function ( $date, $format = null, $timezone = null ) use ( $core ): strin echo "PASS child page inheritance, seven block names, replacement boundaries, nested blocks, and structural WordPress hooks.\n"; } catch ( Throwable $throwable ) { fwrite( STDERR, $throwable->getMessage() . "\n" ); - exit( 1 ); + $exit_code = 1; } finally { if ( is_dir( get_stylesheet_directory() . '/templates' ) ) { rmdir( get_stylesheet_directory() . '/templates' ); @@ -260,3 +262,5 @@ static function ( $date, $format = null, $timezone = null ) use ( $core ): strin rmdir( get_stylesheet_directory() ); } } + +exit( $exit_code ); diff --git a/.github/scripts/wordpress-fixture-smoke.cjs b/.github/scripts/wordpress-fixture-smoke.cjs index c7c0f64..b359c86 100755 --- a/.github/scripts/wordpress-fixture-smoke.cjs +++ b/.github/scripts/wordpress-fixture-smoke.cjs @@ -274,7 +274,6 @@ function assertGeneratedChildTheme(themePath, slug) { 'patterns/.gitkeep', 'src/components/.gitkeep', 'project.emulsify.json', - 'templates/page.twig', ]; if (!style.includes('Theme Name: Smoke Generated') || !style.includes('Template: emulsify')) { @@ -312,6 +311,14 @@ function assertGeneratedChildTheme(themePath, slug) { throw new Error('Generated child theme should not include an empty child theme.json by default.'); } + if (fs.existsSync(path.join(themePath, 'templates'))) { + throw new Error('Generated child theme should not include a templates directory by default.'); + } + + if (fs.readdirSync(themePath, { recursive: true }).some((file) => file.endsWith('.twig'))) { + throw new Error('Generated child theme should not include Twig templates by default.'); + } + for (const directory of ['src/editor', 'src/foundation', 'src/layout']) { if (fs.existsSync(path.join(themePath, directory))) { throw new Error(`Generated child theme should not include assumed source directory: ${directory}`); @@ -334,6 +341,7 @@ function installGeneratedChildFixtures(themePath) { fs.mkdirSync(globalDirectory, { recursive: true }); fs.mkdirSync(path.join(acfDirectory, 'css'), { recursive: true }); fs.mkdirSync(groupedDirectory, { recursive: true }); + fs.mkdirSync(path.join(themePath, 'templates'), { recursive: true }); fs.writeFileSync( path.join(groupedDirectory, 'smoke-grouped.html.twig'), '<p class="smoke-grouped-project-component">{{ label }}</p>\n' @@ -685,7 +693,7 @@ async function checkRoute(baseUrl, route) { const expectedStatus = route.status || 200; appendLog('http.log', `\n${route.name} ${route.path}\nStatus: ${response.status}\n`); - appendLog('http.log', body.slice(0, 4000)); + appendLog('http.log', body); appendLog('http.log', '\n'); if (response.status !== expectedStatus) { @@ -762,6 +770,53 @@ async function checkSearchPagination(baseUrl) { } } +async function checkChildBlockExtension(baseUrl, pagePath, themePath, baseline) { + const childTemplate = path.join(themePath, 'templates', 'page.twig'); + const original = fs.existsSync(childTemplate) ? fs.readFileSync(childTemplate, 'utf8') : null; + const before = '<!-- child content extension start -->'; + const after = '<!-- child content extension end -->'; + + try { + fs.mkdirSync(path.dirname(childTemplate), { recursive: true }); + fs.writeFileSync( + childTemplate, + `{% extends '@emulsify-tpl/page.twig' %}{% block content %}${before}{{ parent() }}${after}{% endblock %}` + ); + const extended = await checkRoute(baseUrl, { + name: 'child page block extension', + path: pagePath, + includes: [before, after, 'Smoke Page', 'Smoke page content'], + }); + + // Anonymous fixture responses are deterministic. Compare raw bytes, including + // whitespace and WordPress hook output, without normalizing the document. + if ( + extended.split(before).length !== 2 || + extended.split(after).length !== 2 || + extended.replace(before, '').replace(after, '') !== baseline + ) { + throw new Error('Child block extension must preserve every surrounding byte.'); + } + } + finally { + if (original === null) { + fs.rmSync(childTemplate, { force: true }); + } + else { + fs.writeFileSync(childTemplate, original); + } + } + + const restored = await checkRoute(baseUrl, { + name: 'parent page after child extension cleanup', + path: pagePath, + includes: ['Smoke Page', 'Smoke page content'], + }); + if (restored !== baseline) { + throw new Error('Removing the child block extension must restore the parent page bytes.'); + } +} + async function renderRoutes(wpPath, content, baseUrl, port, themeSlug) { startServer(wpPath, port); await waitForServer(baseUrl); @@ -769,7 +824,9 @@ async function renderRoutes(wpPath, content, baseUrl, port, themeSlug) { const pageIncludes = [ 'Smoke Page', 'Smoke page content', - `${themeSlug}-page`, + `<article class="entry" id="post-${content.pageId}">`, + '<h1 class="entry__title">Smoke Page</h1>', + '<div class="entry__content">', `/wp-content/themes/${themeSlug}/dist/global/smoke.css`, `/wp-content/themes/${themeSlug}/dist/components/smoke-acf/css/smoke-acf.css`, ]; @@ -793,14 +850,31 @@ async function renderRoutes(wpPath, content, baseUrl, port, themeSlug) { { name: '404', path: '/?p=9999999', status: 404, includes: ['Page not found'] }, ]; + let parentPage = null; for (const route of routes) { - await checkRoute(baseUrl, route); + const body = await checkRoute(baseUrl, route); + if (route.name === 'page') { + parentPage = body; + } } + await checkChildBlockExtension( + baseUrl, + `/?page_id=${content.pageId}`, + path.join(wpPath, 'wp-content', 'themes', themeSlug), + parentPage + ); await checkSearchPagination(baseUrl); await checkGeneratedAssets(baseUrl, themeSlug); } +function assertCleanDebugLog(wpPath) { + const debugLog = path.join(wpPath, 'wp-content', 'debug.log'); + if (fs.existsSync(debugLog) && fs.readFileSync(debugLog, 'utf8').trim() !== '') { + throw new Error(`WordPress fixture debug log must remain clean. See ${debugLog}.`); + } +} + function dumpLogs(wpPath) { if (!logDir) { return; @@ -909,8 +983,9 @@ async function main() { const content = createFixtureContent(wpPath); await renderRoutes(wpPath, content, baseUrl, port, generatedChildSlug); stopServer(); + assertCleanDebugLog(wpPath); - log('Generated and activated a child theme, checked block discovery, loaded assets, rendered home, page, single, archive, search, author, and 404 routes, and followed search pagination to page 2.'); + log('Generated and activated a child without Twig templates, checked block discovery, loaded assets, rendered parent page fallbacks and child block extensions without changing surrounding bytes, rendered home, single, archive, search, author, and 404 routes, and followed search pagination to page 2 with a clean debug log.'); dropDatabase(env); if (!keepFixture) { diff --git a/.github/scripts/wordpress-starter-init-smoke.cjs b/.github/scripts/wordpress-starter-init-smoke.cjs index d4ea1c3..6be49bc 100644 --- a/.github/scripts/wordpress-starter-init-smoke.cjs +++ b/.github/scripts/wordpress-starter-init-smoke.cjs @@ -274,7 +274,6 @@ try { const project = readJson(path.join(target, 'project.emulsify.json')); const pattern = readJson(path.join(target, 'patterns/smoke-pattern.json')); const functionsPhp = fs.readFileSync(path.join(target, 'functions.php'), 'utf8'); - const pageTwig = fs.readFileSync(path.join(target, 'templates/page.twig'), 'utf8'); assert(style['Theme Name'] === 'Acme echo 1 Theme', 'style.css should use a source-safe Theme Name.'); assert(!style['Theme Name'].includes('*/'), 'style.css should not contain a crafted comment terminator.'); @@ -294,8 +293,7 @@ try { ); assert(functionsPhp.includes('Acme echo 1 Theme child theme hooks.'), 'functions.php should use a source-safe visible label.'); assert(!functionsPhp.includes('*/ echo 1; /*'), 'functions.php should not contain the attempted docblock breakout.'); - assert(pageTwig.includes('acme-theme-page'), 'templates/page.twig should update the page class.'); - assert(!pageTwig.includes('whisk-page'), 'templates/page.twig should not keep the starter page class.'); + assert(!fs.existsSync(path.join(target, 'templates')), 'Fresh generated child themes should inherit parent templates without a local templates directory.'); assert(pattern.name === 'acme-theme/smoke-pattern', 'JSON pattern names should update the whisk namespace.'); assert( !fs.existsSync(path.join(target, '.cli')), diff --git a/includes/Cli/GenerateChildThemeCommand.php b/includes/Cli/GenerateChildThemeCommand.php index 2b3afe3..e1978ad 100644 --- a/includes/Cli/GenerateChildThemeCommand.php +++ b/includes/Cli/GenerateChildThemeCommand.php @@ -350,15 +350,6 @@ function ( string $contents ) use ( $source_label ): string { } ); - $this->collect_text_update( - $updates, - $root, - 'templates/page.twig', - function ( string $contents ) use ( $machine_name ): string { - return str_replace( self::STARTER_SLUG . '-page', $machine_name . '-page', $contents ); - } - ); - $this->collect_pattern_updates( $updates, $root, $machine_name ); $this->collect_documentation_updates( diff --git a/whisk/.cli/init.js b/whisk/.cli/init.js index f1ffb60..8664ad9 100644 --- a/whisk/.cli/init.js +++ b/whisk/.cli/init.js @@ -243,16 +243,6 @@ const updateFunctionsPhp = ({ sourceLabel }) => { writeTextIfChanged(filePath, contents); }; -const updatePageTemplate = ({ machineName }) => { - const filePath = path.join(ROOT, 'templates/page.twig'); - const contents = readText(filePath).replaceAll( - `${STARTER_SLUG}-page`, - `${machineName}-page`, - ); - - writeTextIfChanged(filePath, contents); -}; - const walkJsonFiles = (directory) => { if (!fs.existsSync(directory)) { return []; @@ -303,7 +293,6 @@ const main = () => { updateLockfile('npm-shrinkwrap.json', project); updateProjectConfig(config, project); updateFunctionsPhp(project); - updatePageTemplate(project); updatePatternNamespaces(project); // Generation-only tooling should not linger in a project repository. Removing diff --git a/whisk/templates/page.twig b/whisk/templates/page.twig deleted file mode 100644 index de5b101..0000000 --- a/whisk/templates/page.twig +++ /dev/null @@ -1,9 +0,0 @@ -{# Generated child-theme example override. It extends the parent-only page - fallback through @emulsify-tpl to avoid recursively extending itself. #} -{% extends '@emulsify-tpl/page.twig' %} - -{% block content %} - <div class="whisk-page"> - {{ parent() }} - </div> -{% endblock %} From 48304b7e905bbb005c04e0aed26d84c68740cd6e Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Thu, 22 Oct 2026 22:30:42 -0500 Subject: [PATCH 015/100] docs: document parent Twig extension and starter inheritance --- README.md | 2 +- UPGRADE.md | 13 +++++ docs/README.md | 1 + docs/generated-child-theme-contract.md | 5 +- docs/parent-child-architecture.md | 5 +- docs/release-notes-next.md | 7 +++ docs/template-extension.md | 76 ++++++++++++++++++++++++++ docs/timber-and-twig-authoring.md | 5 ++ docs/upgrading-1x-to-2x.md | 5 +- docs/wp-cli-child-theme-generation.md | 4 +- whisk/docs/upgrading.md | 9 ++- 11 files changed, 126 insertions(+), 6 deletions(-) create mode 100644 docs/template-extension.md diff --git a/README.md b/README.md index 12c607b..2f1c228 100644 --- a/README.md +++ b/README.md @@ -115,7 +115,7 @@ The generated `whisk` child theme owns project implementation: - `whisk/src/components` is an empty placeholder until a project installs the component system it wants to use. - Source Sass, JavaScript, stories, data fixtures, and component metadata are defined by the selected Emulsify component system, not by this parent theme starter. - `whisk/assets/images` and `whisk/assets/icons` are empty placeholders for project-owned theme media and icon files. -- `whisk/templates/page.twig` is a small example override. +- New generated themes contain no Twig overrides or `templates/` directory. Add project templates only when needed; use the documented [Twig extension points](docs/template-extension.md). - `whisk/dist/global` and `whisk/dist/components` are runtime build output conventions when a component system emits them. - `whisk/project.emulsify.json` uses `"platform": "wordpress"` so Core and CLI tooling can load the WordPress platform adapter. It also records `generatedFrom` and `generatedFromVersion` so future upgrades and support diagnostics can identify Emulsify-generated WordPress child themes. Projects can also use Core-supported metadata such as `variant.structureImplementations` there when a selected component system needs legacy Twig namespaces. diff --git a/UPGRADE.md b/UPGRADE.md index 5f3aa2b..ef8d33b 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -9,6 +9,19 @@ then decide which starter changes to adopt in each generated project. The minimum WordPress version is now 6.8, matching the locked Timber dependency. Sites running WordPress 6.7 must update WordPress before updating the theme. +New generated themes no longer ship `templates/page.twig` or a `templates/` +directory. Existing 2.x children keep their page override: it still inherits +parent fixes when it extends `@emulsify-tpl/page.twig` and calls `parent()`. +Search project CSS for the `.<machine>-page` wrapper, such as `.acme-site-page`, +before deleting that override. Projects can remove it if the wrapper is unused +and the file has no other project customizations. + +The new `site_header`, `main`, and `page_title` layout blocks are additive; the +parent's default output remains byte-identical. Existing `head`, `header`, +`content`, and `footer` blocks retain their names and boundaries. See the +[public template extension contract](docs/template-extension.md) before adding +an override, including how to preserve the skip-link target. + New child themes use Emulsify Core `^4.5.0`. Existing generated themes do not inherit copied starter scripts or configuration when the parent is updated. Updating Core is optional for existing projects; to adopt 4.5.0, change the diff --git a/docs/README.md b/docs/README.md index efedea1..e033b2e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -11,6 +11,7 @@ ## Authoring - [Timber and Twig authoring](timber-and-twig-authoring.md) +- [Parent Twig extension points](template-extension.md) - [Component recipes](component-recipes.md) - [Emulsify Core 4 and Vite workflow](core-4-vite-workflow.md) - [Design token integration](design-token-integration.md) diff --git a/docs/generated-child-theme-contract.md b/docs/generated-child-theme-contract.md index 71f2cb6..57a8cb7 100644 --- a/docs/generated-child-theme-contract.md +++ b/docs/generated-child-theme-contract.md @@ -15,7 +15,10 @@ These are separate implementations in different languages, so the release checks ## What the contract guarantees -- The machine name, display name, and description are applied consistently to `style.css` headers, `package.json`, `project.emulsify.json`, `functions.php`, `templates/`, and `patterns/`. +- The machine name, display name, and description are applied consistently to `style.css` headers, `package.json`, `project.emulsify.json`, `functions.php`, and `patterns/`. +- A fresh generated theme ships no Twig templates or `templates/` directory; + the parent renders page fallbacks. Consumers can add their own overrides + later using the [template extension contract](template-extension.md). - `style.css` keeps `Template: emulsify` so WordPress loads the Emulsify parent runtime, and preserves the starter's license metadata. - `package.json` is valid JSON, uses the generated machine name, retains every Emulsify Core npm script, and keeps the starter's `@emulsify/core` range. - `project.emulsify.json` is valid JSON and retains `platform`, the generated `machineName`, `generatedFrom`, `generatedFromVersion`, and the `starter` repository block. diff --git a/docs/parent-child-architecture.md b/docs/parent-child-architecture.md index 6ba8355..3dedb0c 100644 --- a/docs/parent-child-architecture.md +++ b/docs/parent-child-architecture.md @@ -37,7 +37,10 @@ The bundled Whisk starter is an example child theme. Real projects can generate ## Template fallback model -The parent `templates/` directory contains complete minimal fallbacks. The generated child theme only ships a small example override at `whisk/templates/page.twig`. +The parent `templates/` directory contains complete minimal fallbacks. Generated +child themes ship no Twig templates or `templates/` directory. Pages render +through the parent's `page.php` and Twig fallback until the project creates an +intentional override. See the [public Twig extension points](template-extension.md). Timber resolves the `@templates` namespace child-first: diff --git a/docs/release-notes-next.md b/docs/release-notes-next.md index 0296475..9728f9f 100644 --- a/docs/release-notes-next.md +++ b/docs/release-notes-next.md @@ -33,6 +33,9 @@ Title: `2.1.0` ### Added +- Public `site_header`, `main`, and `page_title` layout extension blocks, with + byte-for-byte golden coverage and documented boundaries. Existing layout + blocks and default parent output remain unchanged. - Timber-first WordPress parent theme with Twig route fallbacks, global context, namespace registration, and Emulsify-compatible switch tags. - `wp emulsify` WP-CLI child theme generation with `--machine-name`, @@ -51,6 +54,10 @@ Title: `2.1.0` ### Changed +- Fresh generated themes ship no Twig overrides or `templates/` directory. + Pages render through parent fallbacks. Existing child page overrides keep + inheriting parent fixes; search project CSS for the machine-name wrapper + before deciding whether to remove one. - New generated themes use Emulsify Core `^4.5.0`; their effective Node.js floor is 24.13.0. Existing projects should refresh their own dependency lockfiles when adopting this optional Core update. diff --git a/docs/template-extension.md b/docs/template-extension.md new file mode 100644 index 0000000..fc14119 --- /dev/null +++ b/docs/template-extension.md @@ -0,0 +1,76 @@ +# Extend parent Twig templates + +New generated child themes ship no Twig templates or `templates/` directory. +WordPress falls back to the parent's PHP route controllers, and the parent +`@templates` namespace supplies the corresponding Twig fallback. Create a child +`templates/` directory only when the project needs an override. + +## Extend the parent explicitly + +In a child `templates/page.twig`, extend the parent-only namespace: + +```twig +{% extends '@emulsify-tpl/page.twig' %} + +{% block content %} + {{ parent() }} + <p class="project-notice">Thank you for visiting.</p> +{% endblock %} +``` + +WordPress selects the parent's `page.php`, which renders `@templates/page.twig`. +That child-first lookup selects the child's override. Its `@emulsify-tpl` extends +then selects the parent explicitly, so surrounding layout and later parent fixes +continue to reach the child. `parent()` preserves the selected block's default +contents; omit it when intentionally replacing that block. + +Using `{% extends '@templates/page.twig' %}` inside the child file with that same +path resolves back to itself and can recurse. Use `@templates` for normal +child-first includes, and `@emulsify-tpl` for same-path parent inheritance. The +same rule applies to a child `templates/layouts/base.twig`. + +## Public extension points + +The layout and route blocks below are a public contract. Their names and +boundaries are stable: renaming, removing, or changing a boundary incompatibly +is a breaking change. The existing `head`, `header`, `content`, and `footer` +blocks retain their original names and boundaries. + +| Parent template | Block | Default contents | +| --- | --- | --- | +| `layouts/base.twig` | `head` | The head partial, including metadata, the `get_header` action, and `wp_head()` | +| `layouts/base.twig` | `site_header` | The complete site `<header>`, its inner div, brand link, and optional primary navigation | +| `layouts/base.twig` | `header` | Brand link and optional navigation inside the site header's inner div | +| `layouts/base.twig` | `main` | The complete `<main id="content" tabindex="-1">`, including `page_title` and `content` | +| `layouts/base.twig` | `page_title` | The conditional page-title header and its h1; empty when `title` is unset or empty | +| `layouts/base.twig` | `content` | Empty; route templates provide the page-specific contents | +| `layouts/base.twig` | `footer` | The footer partial and copyright markup | +| `page.twig` | `content` | Page article with title/content, or the empty-page message | +| `single.twig` | `content` | Post article, metadata, content, and comments | +| `single-password.twig` | `content` | Password-protected post form | +| `index.twig`, `archive.twig`, `author.twig`, `search.twig` | `content` | Teasers, pagination, or the route's empty-result message | +| `404.twig` | `content` | Not-found heading and message | + +Replacing `site_header` replaces its nested `header` unless the override calls +`parent()`. Replacing `main` similarly replaces its nested `page_title` and +`content`. Preserve `id="content"` and `tabindex="-1"` on your main element so +the skip link remains usable. Keep `wp_head()` when replacing the `head` block, +usually by calling `parent()`. + +The base layout keeps `wp_body_open()`, the skip link, `wp_footer()`, and the +`get_footer` action outside every overridable block. Block overrides therefore +preserve these WordPress hooks and the skip link. + +## Verification + +Run `npm run smoke:template-extension` after `composer install`. It compares +literal output bytes with goldens captured before the added blocks, including +title/menu presence and populated/empty content. The harness uses real parent +namespaces and helpers with deterministic WordPress stubs and a frozen footer +clock. It also checks a temporary child's parent-only inheritance and verifies +that overriding one block preserves every surrounding byte. + +The full WordPress fixture verifies a generated child renders pages without a +local Twig template, then temporarily adds a one-block page override, compares +HTTP response bytes, and restores the child afterward. Run it through +`npm run release:check` with WP-CLI and MySQL available. diff --git a/docs/timber-and-twig-authoring.md b/docs/timber-and-twig-authoring.md index 40e51ce..1aad0e8 100644 --- a/docs/timber-and-twig-authoring.md +++ b/docs/timber-and-twig-authoring.md @@ -25,6 +25,11 @@ The parent theme registers these namespaces: Use `@templates` for normal includes. Use `@emulsify-tpl` when a child override extends the parent template with the same relative path. +New generated themes inherit parent fallbacks without local Twig templates. +Create `templates/` when an override is needed. The [template extension +contract](template-extension.md) documents layout blocks, stable boundaries, +and the same-path inheritance recursion pitfall. + ```twig {% extends '@emulsify-tpl/page.twig' %} diff --git a/docs/upgrading-1x-to-2x.md b/docs/upgrading-1x-to-2x.md index 57b11c7..2976142 100644 --- a/docs/upgrading-1x-to-2x.md +++ b/docs/upgrading-1x-to-2x.md @@ -46,7 +46,10 @@ When a child template extends the parent template with the same relative path, u {% extends '@emulsify-tpl/page.twig' %} ``` -Using `@templates/page.twig` from `whisk/templates/page.twig` would resolve back to the child file. +Using `@templates/page.twig` from a child `templates/page.twig` would resolve +back to the child file. New generated themes ship no Twig templates or +`templates/` directory; create one only when needed. See the [template extension +contract](template-extension.md) for the supported blocks and boundaries. ## Block metadata changes diff --git a/docs/wp-cli-child-theme-generation.md b/docs/wp-cli-child-theme-generation.md index 47e37b3..75b3568 100644 --- a/docs/wp-cli-child-theme-generation.md +++ b/docs/wp-cli-child-theme-generation.md @@ -122,9 +122,11 @@ The hook deliberately mirrors the parent WP-CLI generator's targeted updates: - `package.json` name and generated package lockfile root name. - `project.emulsify.json` project name, machine name, generated source metadata, and `"platform": "wordpress"`. - Visible Whisk labels in known starter files. -- `templates/page.twig` class from `whisk-page` to the generated machine-name class. - JSON pattern names from the `whisk/*` namespace to the generated machine-name namespace. +No Twig templates or `templates/` directory are generated. Create project +overrides only as needed, using the [template extension contract](template-extension.md). + The generated child theme must keep `Template: emulsify`; the parent theme remains installed separately as `emulsify`. ## After generation diff --git a/whisk/docs/upgrading.md b/whisk/docs/upgrading.md index fedfad9..12bcbe7 100644 --- a/whisk/docs/upgrading.md +++ b/whisk/docs/upgrading.md @@ -20,13 +20,20 @@ An npm dependency update is different from adopting a newer starter release. A n 3. Compare the two themes, focusing on: - `package.json`, `project.emulsify.json`, and `.nvmrc`; - `config/emulsify-core/` plus the Vite, Storybook, Jest, lint, and formatting scripts; - - `style.css` headers, `functions.php`, `templates/`, and `patterns/`; + - `style.css` headers, `functions.php`, and `patterns/`; - the README, development guide, upgrade guidance, and support checklist. 4. Port only the changes that benefit this project. Project-owned templates and components are not overwritten by a starter release. 5. Delete the temporary comparison theme. Starter releases do not provide an automatic project diff or migration. +New generated themes ship no Twig templates or `templates/` directory. Existing +2.x page overrides still inherit parent fixes when they extend +`@emulsify-tpl/page.twig` and call `parent()`. Before deleting an old page +override, search project CSS for its machine-name page wrapper class and keep +the override if that wrapper is used. Create project templates only as needed; +the parent's `docs/template-extension.md` documents the supported layout blocks. + ## Validate the result ```bash From 04f765eb7065e4624a7bb98eb0daa0619e75b9ad Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Thu, 22 Oct 2026 22:43:27 -0500 Subject: [PATCH 016/100] feat(whisk): ship AI guidance with generated child themes --- .github/scripts/docs-command-check.cjs | 10 ++ .github/scripts/generated-theme-contract.cjs | 58 ++++++- .../scripts/generated-theme-contract.test.cjs | 113 ++++++++++++- .github/scripts/generation-parity-smoke.cjs | 16 ++ .github/scripts/release-check.cjs | 27 +++ UPGRADE.md | 7 + docs/generated-child-theme-contract.md | 17 ++ docs/release-notes-next.md | 6 + package-lock.json | 1 + package.json | 1 + scripts/build-dist.sh | 11 ++ .../emulsify-wordpress-integration/SKILL.md | 36 ++++ whisk/AGENTS.md | 28 ++++ whisk/README.md | 15 ++ whisk/docs/development.md | 28 +++- whisk/docs/wordpress-integration.md | 156 ++++++++++++++++++ 16 files changed, 519 insertions(+), 11 deletions(-) create mode 100644 whisk/.agents/skills/emulsify-wordpress-integration/SKILL.md create mode 100644 whisk/AGENTS.md create mode 100644 whisk/docs/wordpress-integration.md diff --git a/.github/scripts/docs-command-check.cjs b/.github/scripts/docs-command-check.cjs index 72b3d3b..fd6276a 100644 --- a/.github/scripts/docs-command-check.cjs +++ b/.github/scripts/docs-command-check.cjs @@ -98,6 +98,16 @@ const ROOT_CHECKS = [ // against the Whisk source and once against real generated output, so a command // cannot drift between the template and the theme a project actually receives. const THEME_DOC_CHECKS = [ + ...[ + 'AGENTS.md', + 'docs/wordpress-integration.md', + '.agents/skills/emulsify-wordpress-integration/SKILL.md', + ].map((relativePath) => ({ + relativePath, + packagePath: 'package.json', + packageLabel: 'the generated child theme', + includeInlineCode: true, + })), { relativePath: 'README.md', packagePath: 'package.json', diff --git a/.github/scripts/generated-theme-contract.cjs b/.github/scripts/generated-theme-contract.cjs index c89cef3..cb12622 100644 --- a/.github/scripts/generated-theme-contract.cjs +++ b/.github/scripts/generated-theme-contract.cjs @@ -15,6 +15,7 @@ const fs = require('fs'); const path = require('path'); const { isDeepStrictEqual } = require('util'); +const yaml = require('js-yaml'); const { validateDocumentation } = require('./docs-command-check.cjs'); @@ -24,6 +25,7 @@ const SECTION_LABELS = { frontend: 'frontend metadata', references: 'file references', documentation: 'documentation', + guidance: 'AI guidance', placeholders: 'placeholder replacement', build: 'build', }; @@ -50,6 +52,12 @@ const REQUIRED_GENERATED_FILES = [ 'docs/upgrading.md', ]; +const REQUIRED_GUIDANCE_FILES = [ + 'AGENTS.md', + 'docs/wordpress-integration.md', + '.agents/skills/emulsify-wordpress-integration/SKILL.md', +]; + // Generation-only tooling and build output must never reach a project repo. const FORBIDDEN_GENERATED_PATHS = ['.cli', 'node_modules', 'dist', '.out', '.coverage']; @@ -180,10 +188,10 @@ function checkEqual(addError, section, themeLabel, file, key, actual, want) { } function validateGenerationShape(validator, contract) { - const { themeDir, themeLabel, sourceDir } = contract; + const { themeDir, themeLabel, sourceDir, checkBuiltAssets } = contract; const entries = listFilesRecursive(themeDir); const relativeFiles = new Set( - entries.filter((entry) => entry.file).map((entry) => entry.relativePath), + entries.filter((entry) => entry.file).map((entry) => entry.relativePath.split(path.sep).join('/')), ); for (const requiredFile of REQUIRED_GENERATED_FILES) { @@ -208,6 +216,9 @@ function validateGenerationShape(validator, contract) { } for (const forbidden of FORBIDDEN_GENERATED_PATHS) { + if (checkBuiltAssets && forbidden !== '.cli') { + continue; + } if (fs.existsSync(path.join(themeDir, forbidden))) { validator.addError( 'generation', @@ -460,6 +471,8 @@ function validateMarkdownReferences(validator, contract, entries) { } const contents = readTextFile(path.join(themeDir, entry.relativePath)); + const relativePath = entry.relativePath.split(path.sep).join('/'); + const section = REQUIRED_GUIDANCE_FILES.includes(relativePath) || relativePath.startsWith('.agents/skills/') ? 'guidance' : 'references'; for (const match of contents.matchAll(/\[[^\]]*\]\(([^)]+)\)/g)) { const target = match[1].replace(/^<|>$/g, '').split('#')[0].trim(); @@ -473,17 +486,15 @@ function validateMarkdownReferences(validator, contract, entries) { target, ); - if (!resolved.startsWith(themeRoot)) { - validator.addError( - 'references', + if (resolved !== themeRoot && !resolved.startsWith(`${themeRoot}${path.sep}`)) { + validator.addError(section, `${themeLabel} has documentation link ${expected(target)} in ${expected(entry.relativePath)} that resolves outside the generated child theme; expected a self-contained path or published package.`, ); continue; } if (!fs.existsSync(resolved)) { - validator.addError( - 'references', + validator.addError(section, `${themeLabel} has documentation link ${expected(target)} in ${expected(entry.relativePath)} that resolves to missing local path ${expected(path.relative(themeDir, resolved))}; expected it inside the generated child theme.`, ); } @@ -491,6 +502,32 @@ function validateMarkdownReferences(validator, contract, entries) { } } +function validateGeneratedGuidance(validator, contract, entries, relativeFiles) { + const { themeDir, themeLabel, sourceDir, checkBuiltAssets } = contract; + for (const relativePath of REQUIRED_GUIDANCE_FILES) { + if (!relativeFiles.has(relativePath)) { + validator.addError('guidance', `${themeLabel} is missing required generated guidance file ${expected(relativePath)}.`); + continue; + } + const source = path.join(sourceDir, relativePath); + if (!checkBuiltAssets && (!fs.existsSync(source) || !fs.readFileSync(path.join(themeDir, relativePath)).equals(fs.readFileSync(source)))) { + validator.addError('guidance', `${themeLabel} changed guidance file ${expected(relativePath)} during generation; expected a byte-for-byte copy of the starter guidance.`); + } + } + + for (const entry of entries.filter((file) => file.file && /^\.agents[\\/]skills[\\/][^\\/]+[\\/]SKILL\.md$/.test(file.relativePath))) { + try { + const frontmatter = readTextFile(path.join(themeDir, entry.relativePath)).match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/); + if (!frontmatter) throw new Error('expected YAML frontmatter bounded by ---'); + const metadata = yaml.load(frontmatter[1]); + if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata) || metadata.name !== path.basename(path.dirname(entry.relativePath))) throw new Error('frontmatter name must match the skill directory'); + if (typeof metadata.description !== 'string' || !metadata.description.trim()) throw new Error('frontmatter description must be a nonempty string'); + } catch (error) { + validator.addError('guidance', `${themeLabel} has invalid skill manifest in ${expected(entry.relativePath)}: ${error.message}.`); + } + } +} + function validateGeneratedDocumentation(validator, contract) { const { themeDir, themeLabel, machineName, displayName, description } = contract; @@ -504,7 +541,8 @@ function validateGeneratedDocumentation(validator, contract) { } for (const error of result.errors) { - validator.addError('documentation', `${themeLabel} ${error}`); + const section = REQUIRED_GUIDANCE_FILES.some((file) => error.replaceAll('\\', '/').includes(`${file}:`)) ? 'guidance' : 'documentation'; + validator.addError(section, `${themeLabel} ${error}`); } const readmePath = path.join(themeDir, 'README.md'); @@ -703,6 +741,7 @@ function validateGeneratedTheme(options) { description: normalizeWhitespace(description), parent: 'emulsify', sourceDir, + checkBuiltAssets, sourcePackage, sourceProjectFile, sourceProject: sourceProjectFile.project, @@ -711,9 +750,10 @@ function validateGeneratedTheme(options) { sourceDescription: normalizeWhitespace(sourceHeaders.Description), }; - const { entries } = validateGenerationShape(validator, contract); + const { entries, relativeFiles } = validateGenerationShape(validator, contract); validateWordPressMetadata(validator, contract); validateFrontendMetadata(validator, contract); + validateGeneratedGuidance(validator, contract, entries, relativeFiles); validateMarkdownReferences(validator, contract, entries); validateGeneratedDocumentation(validator, contract); validatePlaceholders(validator, contract, entries); diff --git a/.github/scripts/generated-theme-contract.test.cjs b/.github/scripts/generated-theme-contract.test.cjs index 3c6298a..03d56f4 100644 --- a/.github/scripts/generated-theme-contract.test.cjs +++ b/.github/scripts/generated-theme-contract.test.cjs @@ -27,6 +27,11 @@ const excludedCopySegments = new Set([ const machineName = 'acme-site'; const displayName = 'Acme Site'; const description = 'Marketing site for Acme, Inc. (rebuild).'; +const guidanceFiles = [ + 'AGENTS.md', + 'docs/wordpress-integration.md', + '.agents/skills/emulsify-wordpress-integration/SKILL.md', +]; function copyStarter(source, destination) { fs.mkdirSync(destination, { recursive: true }); @@ -82,13 +87,14 @@ function generateTheme() { return { workRoot, target }; } -function validate(target) { +function validate(target, options = {}) { return validateGeneratedTheme({ themeDir: target, machineName, displayName, description, sourceDir: starterRoot, + ...options, }); } @@ -157,6 +163,111 @@ test('requires the generated documentation set', () => { }); }); +test('requires all generated AI guidance files', () => { + for (const file of guidanceFiles) { + withGeneratedTheme((target) => { + fs.rmSync(path.join(target, file)); + const result = validate(target); + assert.match(result.format(), /FAIL AI guidance/); + assert.ok(result.errors.some((error) => error.includes(`missing required generated guidance file "${file}"`)), result.format()); + }); + } +}); + +test('preserves guidance bytes during generation and permits project edits after a build', () => { + withGeneratedTheme((target) => { + for (const file of guidanceFiles) { + assert.deepEqual(fs.readFileSync(path.join(target, file)), fs.readFileSync(path.join(starterRoot, file))); + fs.appendFileSync(path.join(target, file), '\nProject guidance customized by the team.\n'); + } + const result = validate(target); + assert.match(result.format(), /FAIL AI guidance/); + for (const file of guidanceFiles) { + assert.ok(result.errors.some((error) => error.includes(`changed guidance file "${file}" during generation`)), result.format()); + } + for (const directory of ['dist', 'node_modules', '.out', '.coverage']) { + fs.mkdirSync(path.join(target, directory)); + } + const built = validate(target, { checkBuiltAssets: true }); + assert.deepEqual(built.errors, [], built.format()); + assert.match(built.format(), /PASS AI guidance/); + + fs.mkdirSync(path.join(target, '.cli')); + const leftover = validate(target, { checkBuiltAssets: true }); + assert.match(leftover.format(), /generation-only or build path "\.cli"/); + }); +}); + +test('validates skill YAML after project customization with actionable errors', () => { + for (const [contents, expected] of [ + ['Missing frontmatter\n', /expected YAML frontmatter/], + ['---\nname: [\n---\n', /invalid skill manifest/], + ['---\n- name: emulsify-wordpress-integration\n---\n', /name must match the skill directory/], + ['---\nname: another-skill\ndescription: Integration guidance\n---\n', /name must match the skill directory/], + ['---\nname: emulsify-wordpress-integration\ndescription: ""\n---\n', /description must be a nonempty string/], + ['---\nname: emulsify-wordpress-integration\ndescription: [Integration guidance]\n---\n', /description must be a nonempty string/], + ]) { + withGeneratedTheme((target) => { + fs.mkdirSync(path.join(target, 'dist')); + fs.writeFileSync(path.join(target, guidanceFiles[2]), contents); + const result = validate(target, { checkBuiltAssets: true }); + assert.match(result.format(), /FAIL AI guidance/); + assert.match(result.format(), expected); + }); + } +}); + +test('accepts a customized skill with folded YAML and local links', () => { + withGeneratedTheme((target) => { + fs.mkdirSync(path.join(target, 'dist')); + fs.writeFileSync(path.join(target, guidanceFiles[2]), '---\r\nname: "emulsify-wordpress-integration"\r\ndescription: >\r\n Project integration\r\n guidance.\r\n---\r\n[Integration guide](../../../docs/wordpress-integration.md)\r\n'); + const result = validate(target, { checkBuiltAssets: true }); + assert.deepEqual(result.errors, [], result.format()); + }); +}); + +test('validates guidance links after customization and rejects missing or escaping targets', () => { + withGeneratedTheme((target) => { + fs.mkdirSync(path.join(target, 'dist')); + const skill = path.join(target, guidanceFiles[2]); + fs.appendFileSync(skill, '\n[Integration guide](../../../docs/wordpress-integration.md)\n'); + assert.deepEqual(validate(target, { checkBuiltAssets: true }).errors, []); + + const outside = path.join(path.dirname(target), `${machineName}-external`); + fs.mkdirSync(outside); + fs.writeFileSync(path.join(outside, 'outside.md'), 'Outside the theme.\n'); + fs.appendFileSync(skill, `\n[Missing guide](../../../docs/missing.md)\n[Outside theme](../../../../${machineName}-external/outside.md)\n`); + const result = validate(target, { checkBuiltAssets: true }); + assert.match(result.format(), /FAIL AI guidance/); + assert.match(result.format(), /resolves to missing local path "docs\/missing\.md"/); + assert.match(result.format(), /resolves outside the generated child theme/); + }); +}); + +test('checks npm commands in every generated guidance entry point', () => { + for (const file of guidanceFiles) { + withGeneratedTheme((target) => { + fs.mkdirSync(path.join(target, 'dist')); + fs.appendFileSync(path.join(target, file), '\nRun `npm run impossible-script` for this project.\n'); + const result = validate(target, { checkBuiltAssets: true }); + assert.match(result.format(), /FAIL AI guidance/); + assert.ok(result.errors.some((error) => error.includes(`${file}:`) && error.includes('documents npm run impossible-script')), result.format()); + assert.match(result.format(), /has no "impossible-script" script/); + }); + } +}); + +test('continues checking guidance for stale tokens and starter identity after customization', () => { + withGeneratedTheme((target) => { + fs.mkdirSync(path.join(target, 'dist')); + fs.appendFileSync(path.join(target, guidanceFiles[0]), '\n%%EMULSIFY_MACHINE_NAME%%\n'); + fs.appendFileSync(path.join(target, guidanceFiles[2]), '\nnamespace: whiskHelper\n'); + const result = validate(target, { checkBuiltAssets: true }); + assert.match(result.format(), /stale documentation token/); + assert.match(result.format(), /stale starter machine name "whisk"/); + }); +}); + test('reports leftover documentation tokens', () => { withGeneratedTheme((target) => { const readmePath = path.join(target, 'README.md'); diff --git a/.github/scripts/generation-parity-smoke.cjs b/.github/scripts/generation-parity-smoke.cjs index 0f588d1..bad6edb 100644 --- a/.github/scripts/generation-parity-smoke.cjs +++ b/.github/scripts/generation-parity-smoke.cjs @@ -188,8 +188,21 @@ function collectComparableFiles(root, relative = '', collected = new Map()) { function compareTrees(scenario, wpCliDir, starterDir) { const left = collectComparableFiles(wpCliDir); const right = collectComparableFiles(starterDir); + const guidance = collectComparableFiles(starterRoot, '.agents'); const differences = []; + assert(guidance.size > 0, 'Whisk must ship a non-empty .agents guidance tree.'); + for (const [label, generated] of [['WP-CLI', left], ['starter init', right]]) { + const guidancePaths = [...generated.keys()].filter((file) => file.startsWith(`.agents${path.sep}`)); + if (guidancePaths.length !== guidance.size) { + differences.push(`${label} .agents tree does not contain exactly the source guidance files`); + } + const changed = [...guidance].filter(([file, hash]) => generated.get(file) !== hash); + for (const [relativePath] of changed) { + differences.push(`${label} ${relativePath} does not match the Whisk source bytes`); + } + } + for (const [relativePath, hash] of left) { if (!right.has(relativePath)) { differences.push(`only the WP-CLI path produced ${relativePath}`); @@ -212,6 +225,9 @@ function compareTrees(scenario, wpCliDir, starterDir) { `Generation paths diverged for ${scenario.machineName}:\n- ${differences.join('\n- ')}`, ); + console.log( + `Both generated .agents trees match the Whisk source across ${guidance.size} file${guidance.size === 1 ? '' : 's'} for ${scenario.machineName}.`, + ); return left.size; } diff --git a/.github/scripts/release-check.cjs b/.github/scripts/release-check.cjs index 398486f..8cee491 100644 --- a/.github/scripts/release-check.cjs +++ b/.github/scripts/release-check.cjs @@ -419,6 +419,9 @@ function runStaticChecks() { 'whisk/.cli/init.js', 'whisk/.gitignore', 'whisk/.nvmrc', + 'whisk/AGENTS.md', + 'whisk/docs/wordpress-integration.md', + 'whisk/.agents/skills/emulsify-wordpress-integration/SKILL.md', 'whisk/config/emulsify-core/a11y.config.js', 'whisk/config/emulsify-core/run-a11y.js', 'whisk/config/jest.config.js', @@ -1174,6 +1177,30 @@ function runStaticChecks() { return 'Generated docs cover project ownership, frontend workflows, upgrades, and sanitized support collection.'; }); + runStaticCheck('Generated AI guidance', () => { + const guidancePaths = [ + 'AGENTS.md', + 'docs/wordpress-integration.md', + '.agents/skills/emulsify-wordpress-integration/SKILL.md', + ]; + for (const relativePath of guidancePaths) { + const sourcePath = `whisk/${relativePath}`; + const contents = readFile(sourcePath); + ensure(!/%%EMULSIFY_[A-Z_]+%%|whisk/i.test(contents), `${sourcePath} must be token-free and contain no starter identity so generators copy it unchanged.`); + ensure(generatedThemeContract.includes(`'${relativePath}'`), `Generated theme contract should require ${relativePath}.`); + ensure(docsCommandCheck.includes(`'${relativePath}'`), `Documentation command checks should cover ${relativePath}.`); + ensure(distBuilder.includes(`"${sourcePath}"`), `Distribution build should require staged ${sourcePath}.`); + } + + const attributes = childProcess.spawnSync('git', [ + 'check-attr', 'export-ignore', '--', + 'AGENTS.md', ...guidancePaths.map((file) => `whisk/${file}`), + ], { cwd: repoRoot, encoding: 'utf8' }); + ensure(attributes.status === 0, `Unable to check guidance Git distribution attributes: ${attributes.stderr}`); + ensure(!/: export-ignore: (?:set|true)\s*$/m.test(attributes.stdout), 'Git distributions must retain contributor and generated AI guidance, including hidden skills.'); + return 'Portable guidance is required, copied without replacement, command-checked, and retained in ZIP/Git distributions.'; + }); + runStaticCheck('Generated child theme contract', () => { ensure(rootPackage.scripts['test:generated-theme'] === 'node --test .github/scripts/generated-theme-contract.test.cjs', 'package.json should expose the focused generated child theme contract tests.'); ensure(rootPackage.scripts['smoke:generation-parity'] === 'node .github/scripts/generation-parity-smoke.cjs', 'package.json should expose the generation parity smoke.'); diff --git a/UPGRADE.md b/UPGRADE.md index ef8d33b..bfc5062 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -6,6 +6,13 @@ then decide which starter changes to adopt in each generated project. ## Upgrading from 2.0.0 to 2.1.0 +New generated themes include `AGENTS.md`, a WordPress integration guide, and +the optional `.agents/skills/emulsify-wordpress-integration/` workflow skill. +Existing child themes can copy these token-free files from a fresh comparison +theme and adapt them to their project. Start assistant sessions in the child +theme directory for local skill discovery; generation does not change +site-root agent instructions. + The minimum WordPress version is now 6.8, matching the locked Timber dependency. Sites running WordPress 6.7 must update WordPress before updating the theme. diff --git a/docs/generated-child-theme-contract.md b/docs/generated-child-theme-contract.md index 57a8cb7..c3fffc0 100644 --- a/docs/generated-child-theme-contract.md +++ b/docs/generated-child-theme-contract.md @@ -23,6 +23,12 @@ These are separate implementations in different languages, so the release checks - `package.json` is valid JSON, uses the generated machine name, retains every Emulsify Core npm script, and keeps the starter's `@emulsify/core` range. - `project.emulsify.json` is valid JSON and retains `platform`, the generated `machineName`, `generatedFrom`, `generatedFromVersion`, and the `starter` repository block. - `README.md`, `docs/development.md`, `docs/upgrading.md`, and `docs/support-information.md` are present. The README contains the requested display name, machine name, description, source project, source version, and Emulsify Core range, and the guides document only npm commands that the generated `package.json` exposes. +- `AGENTS.md`, `docs/wordpress-integration.md`, and + `.agents/skills/emulsify-wordpress-integration/SKILL.md` ship unchanged from + the generation source, including the hidden skill directory. The skill has + valid YAML frontmatter, and guidance links resolve inside the child theme. + These files contain no identity placeholders; projects can customize their + copies after generation. - No `%%EMULSIFY_*%%` documentation token survives generation. An unreplaced token fails generation rather than shipping to a project. - No starter identity survives: the machine name `whisk`, the display name `Whisk`, and legacy `EMULSIFY_NAME` placeholders are all absent. - Generation-only tooling and build output are absent, including `.cli`, `node_modules`, `dist`, `.out`, and `.coverage`. @@ -55,6 +61,12 @@ npm run release:check -- --skip-smoke `npm run test:generated-theme` runs focused contract tests that generate a throwaway theme and then mutate it to prove each class of failure is detected. `npm run smoke:generation-parity` generates through both paths and compares them; it requires a PHP binary and skips with a warning when none is available. +The validator's `--check-built-assets` mode checks an installed, built consumer +theme: it requires `dist/`, allows dependency/build directories and customized +guidance, and still rejects `.cli/`. Guidance presence, skill metadata, local +links, and documented commands remain checked; only the fresh-copy byte +comparison is relaxed. + To verify generated audit wrappers against an actual packed Core artifact, run: ```sh @@ -83,6 +95,11 @@ That requires Node.js, PHP, Composer, MySQL, WP-CLI, and network access. - **WordPress metadata:** A `style.css` header does not match the requested identity, or `functions.php` still describes the starter. - **Frontend metadata:** `package.json` or `project.emulsify.json` lost a required key, an npm script, or the Emulsify Core range. - **Documentation:** A required generated guide is missing, still contains a source token, lost project-specific metadata, or documents an npm command the generated package does not expose. Fix the Whisk documentation template or the generation post-processors. +- **AI guidance:** A guidance file is missing or changed during generation, + skill frontmatter is invalid, or a guidance link is missing or escapes the + theme. Fix the source or copy path; byte comparison applies to fresh + generation, while built/customized themes may edit their guidance. Skill + metadata and links must remain valid. - **Placeholder replacement:** A starter name or token survived. Both generators must be updated together; a token added to the templates without a matching replacement in each generator fails the release check. - **File references:** A documentation link or script path escapes the generated theme. diff --git a/docs/release-notes-next.md b/docs/release-notes-next.md index 9728f9f..c2154ab 100644 --- a/docs/release-notes-next.md +++ b/docs/release-notes-next.md @@ -33,6 +33,10 @@ Title: `2.1.0` ### Added +- Generated child themes include portable `AGENTS.md` guidance, a WordPress + integration guide, and an optional integration workflow skill. Both + generators preserve these files unchanged, including hidden `.agents/` + content. - Public `site_header`, `main`, and `page_title` layout extension blocks, with byte-for-byte golden coverage and documented boundaries. Existing layout blocks and default parent output remain unchanged. @@ -113,6 +117,8 @@ Title: `2.1.0` ### Internal +- Re-sync the standalone `emulsify-wordpress-starter` repository from `whisk/` + before publishing, including its hidden `.agents/` directory and AI guidance. - Extended CI verifies the runner's installed Chrome with its sandbox enabled, runs real seeded project tests before accessibility checks, and retains coverage diagnostics. diff --git a/package-lock.json b/package-lock.json index b6dbe7e..6b1c250 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "@semantic-release/github": "^12.0.9", "@semantic-release/release-notes-generator": "^14.1.1", "husky": "^9.1.7", + "js-yaml": "4.3.0", "lint-staged": "^17.3.0", "semantic-release": "^25.0.8" }, diff --git a/package.json b/package.json index ad1e84f..b7f056c 100644 --- a/package.json +++ b/package.json @@ -72,6 +72,7 @@ "@semantic-release/github": "^12.0.9", "@semantic-release/release-notes-generator": "^14.1.1", "husky": "^9.1.7", + "js-yaml": "4.3.0", "lint-staged": "^17.3.0", "semantic-release": "^25.0.8" } diff --git a/scripts/build-dist.sh b/scripts/build-dist.sh index f0705f1..eaa3c49 100755 --- a/scripts/build-dist.sh +++ b/scripts/build-dist.sh @@ -132,6 +132,9 @@ required_release_paths=( "functions.php" "vendor/autoload.php" "whisk/README.md" + "whisk/AGENTS.md" + "whisk/.agents/skills/emulsify-wordpress-integration/SKILL.md" + "whisk/docs/wordpress-integration.md" "whisk/package.json" "whisk/project.emulsify.json" "whisk/style.css" @@ -210,5 +213,13 @@ echo "Creating ${artifact_path}..." find emulsify -type f -print | LC_ALL=C sort | zip -X -q "${temporary_archive}" -@ ) +archive_listing="$(zip -sf "${temporary_archive}")" +for relative_path in "${required_release_paths[@]}"; do + if ! grep -Fqx " emulsify/${relative_path}" <<< "${archive_listing}"; then + echo "Required release path not found in ZIP: ${relative_path}" >&2 + exit 1 + fi +done + mv "${temporary_archive}" "${artifact_path}" echo "Built ${artifact_path}" diff --git a/whisk/.agents/skills/emulsify-wordpress-integration/SKILL.md b/whisk/.agents/skills/emulsify-wordpress-integration/SKILL.md new file mode 100644 index 0000000..69735ec --- /dev/null +++ b/whisk/.agents/skills/emulsify-wordpress-integration/SKILL.md @@ -0,0 +1,36 @@ +--- +name: emulsify-wordpress-integration +description: Integrate or debug project components in an Emulsify WordPress child theme, including Timber template selection, WordPress data mapping, block registration, and asset loading. Use for connecting components to WordPress rendering; component-only styling or stories do not require this workflow. +--- + +# Emulsify WordPress integration + +Read the theme's [AGENTS.md](../../../AGENTS.md) and +[WordPress integration guide](../../../docs/wordpress-integration.md). Resolve +links from this skill's directory, then work from the theme root. + +If `.cli/init.js` exists, follow the enclosing repository's generation workflow +and verify the generated result. For a generated theme: + +1. Read project metadata, theme headers, npm scripts, installed library + guidance, and the closest existing integration. Discover the component's + inputs and supported references from its schema, stories, source, and tests. + Use the component inspector when available; its inventory does not prove + WordPress registration or define the complete component API. +2. Confirm the WordPress route, post type, fields, permissions, active child + theme, and actual PHP/Twig candidates. Inspect the installed parent's + templates and hooks before adding an override or data transformation. +3. Keep project mappings in child templates, `functions.php`, or an appropriate + project plugin. Prefer a parent Twig extension block when it fits. Preserve + WordPress hooks, filtered content, escaping, translations, the skip target, + and accessible interaction while passing the component's explicit inputs. +4. Follow the library's build and asset contract. Rebuild changed output, + refresh applicable discovery/site caches, run relevant project checks, and + render the affected route in WordPress. Check anonymous and administrator views, + editor previews, block registration, and debug logs where relevant. + +## Report back + +Report the selected template/component, mapping or asset change, checks run, +and exact WordPress behavior still unverified. Storybook does not execute the +site's Timber context, ACF, or WordPress hooks. diff --git a/whisk/AGENTS.md b/whisk/AGENTS.md new file mode 100644 index 0000000..c6fcc34 --- /dev/null +++ b/whisk/AGENTS.md @@ -0,0 +1,28 @@ +# Theme guidance + +If `.cli/init.js` exists, this directory is a generation source. Follow the +enclosing repository's contributor instructions and validate generated output. +An installed parent may also bundle generation source without `.cli/`; check +enclosing instructions before treating this directory as a project-owned theme. + +Read [README.md](README.md), [the development guide](docs/development.md), +`project.emulsify.json`, `package.json`, and `style.css` before changing theme +code. Discover identity, available commands, dependencies, and installed +component structure from those files and existing project code. Preserve +`Template: emulsify`, `generatedFrom`, and `generatedFromVersion`. + +For WordPress template or component integration, use +[the integration skill](.agents/skills/emulsify-wordpress-integration/SKILL.md) +and [the integration guide](docs/wordpress-integration.md). For upgrades or +failures, read [upgrading](docs/upgrading.md) or +[support information](docs/support-information.md). + +Read `docs/component-library.md` when the installed library supplies it, along +with relevant component schemas, stories, source, and local instructions. The +library owns its layout, APIs, tokens, and build inputs; the project owns its +WordPress data mappings and hooks. Discover these before choosing an approach. + +Run frontend commands from this theme directory. Select checks from +`package.json` for the changed behavior and available build inputs, then verify +WordPress integration in the actual site. A passing story does not prove +Timber context, block registration, or WordPress hooks work. diff --git a/whisk/README.md b/whisk/README.md index ee8898c..660c3cf 100644 --- a/whisk/README.md +++ b/whisk/README.md @@ -37,5 +37,20 @@ The generated theme does not include project asset source files or active asset ## Documentation - [Development guide](./docs/development.md) - Setup, commands, assets, and project ownership. +- [WordPress integration](./docs/wordpress-integration.md) - Timber template selection, component mapping, and site verification. - [Upgrading](./docs/upgrading.md) - Dependency updates and starter-release comparisons. - [Troubleshooting and support](./docs/support-information.md) - Common fixes and a sanitized diagnostic checklist. + +## Working with AI + +[AGENTS.md](AGENTS.md) routes an assistant to this theme's metadata and guides. +The optional +[WordPress integration skill](.agents/skills/emulsify-wordpress-integration/SKILL.md) +supports connecting project components to WordPress. The selected component +library supplies its own structure, APIs, assets, and token guidance. + +Start an AI session from this theme directory for local skill discovery. For a +session started at the WordPress site root, explicitly read this theme's +`AGENTS.md` and skill, or reference them in the site's existing agent +instructions. Generation leaves site-root instructions unchanged and does not +configure an AI tool. See [the development guide](docs/development.md) for setup. diff --git a/whisk/docs/development.md b/whisk/docs/development.md index 2401a8f..0a3f2df 100644 --- a/whisk/docs/development.md +++ b/whisk/docs/development.md @@ -53,7 +53,33 @@ npm run inspect:components -- --json npm run inspect:components -- --help ``` -The inspector is safe to run before a component library exists. A component-neutral project returns a valid empty report. +The inspector locates non-partial Twig templates recognized by Emulsify Core +and reports source/output paths, references, and namespace collisions. It is +safe to run before a component library exists; a component-neutral project +returns a valid empty report. Read component schemas, stories, and source for +their APIs. The inventory does not prove WordPress block registration or cover +every component type. + +## AI-assisted development + +The theme's [AGENTS.md](../AGENTS.md) routes assistants to project facts and +guides. The optional +[WordPress integration skill](../.agents/skills/emulsify-wordpress-integration/SKILL.md) +uses [the integration guide](wordpress-integration.md) for Timber template +selection, WordPress data mapping, assets, and site verification. + +Start a session from this generated theme directory when an assistant discovers +local `.agents/skills/` from its working directory. A session started at the +WordPress site root may need an explicit instruction to read the theme's +`AGENTS.md` and skill. Teams can add that routing to existing site-root +instructions or install the skill through their assistant's supported +mechanism. Generation leaves site-root instructions unchanged and does not +configure a developer's AI tool. + +Read `docs/component-library.md` when the installed library supplies it, plus +local component instructions and contracts. Its directories, props, slots, +tokens, entrypoints, and build integration remain library-specific; WordPress +data mappings and project conventions belong to this theme or a project plugin. ## Development workflow diff --git a/whisk/docs/wordpress-integration.md b/whisk/docs/wordpress-integration.md new file mode 100644 index 0000000..ebde304 --- /dev/null +++ b/whisk/docs/wordpress-integration.md @@ -0,0 +1,156 @@ +# Integrate project components with WordPress + +This guide covers the WordPress side of an Emulsify child theme. The installed +component library defines its own structure, APIs, tokens, entrypoints, and +build outputs. The project maps WordPress content to those components. + +## Discover the project and component + +Work from the generated theme root. Read [AGENTS.md](../AGENTS.md), +`project.emulsify.json`, `package.json`, `style.css`, existing templates, and +project hooks. Check `project.machineName`, the child theme's `Text Domain`, +and `Template: emulsify`; preserve `generatedFrom` and `generatedFromVersion`. + +If `.cli/init.js` exists, this directory is a generation source. Follow the +enclosing repository's workflow and verify generated output before treating a +source edit as a working project integration. + +Read `docs/component-library.md` when installed, plus relevant local +instructions, schemas, stories, source, and tests. Project metadata such as +`system` and `variant.structureImplementations` can identify the selected +library and source roots. Discover actual component inputs, slots, assets, and +namespace mappings instead of importing another project's conventions. + +After installing npm dependencies, inspect the available component inventory: + +```bash +npm run inspect:components -- --json +``` + +The inspector locates non-partial Twig templates recognized by Emulsify Core +and reports source/output paths, references, and namespace collisions. An +empty project returns a valid empty inventory. This does not establish the +complete component API or prove registration in WordPress. + +## Select the WordPress extension point + +Confirm the actual query, post type, content fields, permissions, and active +theme in the site. WordPress first chooses a PHP controller through its normal +template hierarchy; the parent's controllers then pass candidates to Timber: + +- `page.php` renders `@templates/page.twig`. +- `single.php` tries `@templates/single-{post_type}.twig` before + `@templates/single.twig`; protected posts use `single-password.twig`. +- Listing fallbacks try `@templates/partials/tease-{post_type}.twig` before + `@templates/partials/tease.twig` for each result. + +Inspect the installed controller and existing child overrides rather than +assuming that a route uses a particular template. Locate the parent through +WordPress's `get_template_directory()` and read its `docs/template-extension.md` +and matching templates. Installed parent paths vary between sites. + +Fresh generated themes contain no Twig templates or `templates/` directory. +Create only the overrides needed. `@templates` searches child templates before +parent fallbacks; `@emulsify-tpl` selects only the parent. A same-path child +override must extend the latter to avoid resolving back to itself: + +```twig +{% extends '@emulsify-tpl/page.twig' %} + +{% block content %} + {{ parent() }} + <!-- project content extension --> +{% endblock %} +``` + +Inspect the installed parent's extension blocks before replacing markup. +The layout exposes `head`, `site_header`, `header`, `main`, `page_title`, +`content`, and `footer`; the installed parent's guide defines their boundaries. +Preserve `wp_head()`, `wp_body_open()`, `wp_footer()`, and applicable WordPress +actions. Keep the skip link and its `#content` target, including the main +element's focus behavior, along with headings, accessible names, and keyboard +interaction. Calling `parent()` keeps a block's inherited contents. + +## Map WordPress data to components + +Keep project-specific hooks in `functions.php` or an appropriate project +plugin. Use the parent's `emulsify_theme_context` filter to extend global Timber +context, preserving inherited `site`, `theme`, `post`, `menu`, and `wp` values +unless intentionally replacing them. Inspect the component's contract and +actual post, taxonomy, media, or ACF data before choosing a mapping; do not +assume example fields exist. + +New project references use `project_machine_name:component_name` or +`@project_machine_name/component_name`, taking the machine name from project +metadata. Both support grouped components. The compatible +`@components/path/to/template.twig` namespace remains available; custom library +namespaces can use `variant.structureImplementations`. Confirm the selected +root and exact path when duplicate names exist. + +Pass only the component's documented inputs. Twig `only` isolates include +context, so explicitly provide every required value, attribute, and slot. +Timber's Twig environment autoescapes HTML. Use `raw` only for trusted rendered +HTML, such as `post.content` after WordPress content filtering; never use it to +bypass escaping on plain ACF values, query parameters, or editor-entered text. +The `bem()` and `add_attributes()` helpers escape their own attribute values. +Keep UI labels translatable using the child's `Text Domain` from `style.css`, +normally its machine name, and preserve relevant content filtering and access +or password checks. + +Choose the project's existing WordPress integration type intentionally: + +- Plain Twig components are included from project templates or other components. +- ACF/Twig blocks require active ACF, built `*.component.json` metadata, and a + matching Twig template under `dist/components`. Their render context includes + `block`, `fields`, `content`, and `is_preview` alongside normal Timber context. +- Native blocks use built `block.json` folders registered through WordPress's + Block API. Keep their assets in native metadata such as `style`, `editorScript`, + and `viewScript`; a plain Twig file does not register a block. +- JSON files directly under `patterns/` supply serialized block patterns. + Add child `theme.json` only for intentional editor/global-style settings, + presets, or styles; it does not configure Twig namespaces. + +For an existing core block intentionally mapped to Twig, inspect the installed +parent's `docs/core-block-twig-rendering.md` and the project's mapping. That +optional bridge is separate from registering a new block. + +Storybook does not execute the site's Timber context, ACF, or WordPress hooks. +Supply story inputs explicitly and verify PHP helper behavior in WordPress. + +## Build and verify the integration + +Follow the library's build contract and use scripts in this theme's +`package.json`. The parent discovers built child output under `dist/global` +and `dist/components` before parent output, preferring a valid asset manifest +when supplied. Rebuild changed sources and inspect emitted files and loaded +assets. A source edit alone does not update a built block. Extend shared +tooling through `config/emulsify-core/`; keep project edits out of installed +`node_modules` packages. + +Built component discovery is memoized per request and may use a persistent +cache outside local/development or `WP_DEBUG` environments. After changing +discovered block metadata or deployment output, clear the current discovery +key through the site's configured maintenance workflow when necessary: + +```php +\Emulsify\Theme\Blocks\ComponentLocator::clear_discovery_cache(); +``` + +This does not clear unrelated WordPress page caches or replace a build. Source +Twig group lookup is cached only for the request. Refresh other site or Twig +caches through the site's existing tooling when applicable. + +Confirm the intended child theme is active, activating it through the site's +local/test workflow when needed. Run relevant lint, tests, builds, inspection, +and accessibility checks from [the development guide](development.md); builds +and Storybook need library inputs, and the default test command fails when no +tests exist. Render the affected WordPress route anonymously and as an +administrator. Check empty/populated content, translations, permissions, password-protected +content, editor previews and block insertion as relevant. Verify assets and +keyboard behavior, and inspect `wp-content/debug.log` with local debugging +enabled. Keep debug settings out of production configuration. + +Report the selected template/component, mappings or assets changed, checks +completed, and exact site behavior left unverified when WordPress or required +tooling is unavailable. See [support information](support-information.md) for +diagnostics and [upgrading](upgrading.md) for existing project updates. From 951cdf128d91bc32bb1cd04bd92f15c80413bae9 Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Thu, 22 Oct 2026 22:43:57 -0500 Subject: [PATCH 017/100] docs: add WordPress contributor guidance --- .github/scripts/release-check.cjs | 1 + AGENTS.md | 78 +++++++++++++++++++++++++++++++ README.md | 3 ++ docs/release-notes-next.md | 2 +- scripts/build-dist.sh | 2 + 5 files changed, 85 insertions(+), 1 deletion(-) create mode 100644 AGENTS.md diff --git a/.github/scripts/release-check.cjs b/.github/scripts/release-check.cjs index 8cee491..5126644 100644 --- a/.github/scripts/release-check.cjs +++ b/.github/scripts/release-check.cjs @@ -328,6 +328,7 @@ function runStaticChecks() { '.github/workflows/theme-readiness.yml', '.gitignore', '.nvmrc', + 'AGENTS.md', 'README.md', 'UPGRADE.md', 'docs/README.md', diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..2006c21 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,78 @@ +# Emulsify WordPress contributor guidance + +## Repository map + +- This repository distributes the `emulsify` WordPress parent theme, not a site. +- `includes/` owns reusable PHP runtime services; root PHP route controllers and + `templates/` provide Timber fallbacks and public Twig extension blocks. +- `whisk/` is the generation source for project-owned child themes. Its + `AGENTS.md`, integration guide, and skill describe generated-project work. + While changing that source here, follow this contributor workflow and verify + the generated result. +- `whisk/config/emulsify-core/` supplies copied project extension points. Shared + frontend tooling belongs to the installed `@emulsify/core` package. +- `.github/scripts/` and `.github/fixtures/` implement maintainer checks and + consumer contracts; they are not generated-project commands. +- `scripts/build-dist.sh` builds the installable parent ZIP, including the + starter payload and production Composer dependencies. + +Read [README.md](README.md), [UPGRADE.md](UPGRADE.md), and the relevant contracts +in `docs/`, especially [sister-project parity](docs/sister-project-parity.md), +[generated themes](docs/generated-child-theme-contract.md), and +[template extension](docs/template-extension.md). + +## Ownership boundaries + +The parent owns reusable WordPress integration, context, discovery, assets, +and fallback rendering. Project-specific templates, components, stories, +styling, and content mappings belong in the generated child; functionality +independent of the theme belongs in a project plugin. The parent adds no +frontend CSS. Keep WordPress behavior native rather than importing Drupal +names or mechanics. + +Keep the starter component-library-neutral. Discover the selected library's +directories, APIs, namespaces, tokens, stories, and build inputs from its guide +and existing project code. Do not scaffold one library's conventions into the +starter. Keep generated guidance free of starter identity and documentation +tokens so it copies unchanged; its links must resolve inside the child theme. + +Child generation has two implementations: +`includes/Cli/GenerateChildThemeCommand.php` and `whisk/.cli/init.js`. Update +both when their behavior changes. Preserve hidden `.agents/` skills through +both generators, the installable ZIP, and Git distributions. Only generation +tooling such as `.cli/` is removed. + +Preserve Twig autoescaping, translations, accessibility, WordPress hooks, and +the `#content` skip-link target when changing rendering. Existing Twig block +names and boundaries are public contracts; check their docs and exact-output +fixtures before changing them. + +## Validation and releases + +Use root `package.json` for maintainer commands and a generated theme's own +package for consumer commands. Install with `composer install` and +`npm ci --ignore-scripts`, then run: + +```sh +npm run lint:php +npm run pr:check +npm run test:generated-theme +npm run smoke:generation-parity +npm run release:check +``` + +`npm run docs:check-commands` checks documented npm commands. For static release +checks use `npm run release:check -- --skip-smoke`. Changes to rendering or +generation need the full WordPress fixture with WP-CLI and MySQL; set +`WP_SMOKE_REQUIRED=1` to fail when prerequisites are missing. Report checks run, +skips and their reasons, and any behavior still unverified. Storybook does not +exercise Timber context, ACF, or WordPress hooks. Empty generated projects have +no tests and intentionally fail `npm run test`; builds need library inputs. + +Use Conventional Commits. Do not bump versions outside the +[release procedure](docs/release-process.md): root `package.json` is the version +source, derived metadata must match, and publishing requires semantic-release +to compute the prepared committed version. Release checks require full Git +history and stable tags. Preserve generated-source lineage and unrelated +working-tree changes. Put user-facing changes in +`docs/release-notes-next.md` and consumer actions in `UPGRADE.md`. diff --git a/README.md b/README.md index 2f1c228..090cfa8 100644 --- a/README.md +++ b/README.md @@ -212,6 +212,9 @@ Emulsify WordPress is licensed under GPL-2.0-only. See [LICENSE](LICENSE). ## Contributing +Read the [contributor guidance](AGENTS.md) for repository ownership, both child +generators, validation commands, and release rules. + ### Code of conduct Read the [Code of Conduct](https://github.com/emulsify-ds/emulsify-wordpress/blob/main/CODE_OF_CONDUCT.md) before contributing. File bugs and feature requests at [emulsify-ds/emulsify-wordpress](https://github.com/emulsify-ds/emulsify-wordpress/issues). diff --git a/docs/release-notes-next.md b/docs/release-notes-next.md index c2154ab..b48242e 100644 --- a/docs/release-notes-next.md +++ b/docs/release-notes-next.md @@ -36,7 +36,7 @@ Title: `2.1.0` - Generated child themes include portable `AGENTS.md` guidance, a WordPress integration guide, and an optional integration workflow skill. Both generators preserve these files unchanged, including hidden `.agents/` - content. + content. Root contributor guidance covers maintainer ownership and checks. - Public `site_header`, `main`, and `page_title` layout extension blocks, with byte-for-byte golden coverage and documented boundaries. Existing layout blocks and default parent output remain unchanged. diff --git a/scripts/build-dist.sh b/scripts/build-dist.sh index eaa3c49..802187e 100755 --- a/scripts/build-dist.sh +++ b/scripts/build-dist.sh @@ -54,6 +54,7 @@ mkdir -p "${staging_root}" runtime_files=( "404.php" + "AGENTS.md" "LICENSE" "README.md" "UPGRADE.md" @@ -127,6 +128,7 @@ if [[ ! -f "${staging_root}/vendor/autoload.php" ]]; then fi required_release_paths=( + "AGENTS.md" "UPGRADE.md" "docs/wp-cli-child-theme-generation.md" "functions.php" From 9c91e969a9b728e278009866ce0e92d2664bc668 Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Thu, 22 Oct 2026 22:47:39 -0500 Subject: [PATCH 018/100] fix(whisk): contain guidance links within generated themes --- .github/scripts/generated-theme-contract.cjs | 10 ++++++++++ .../scripts/generated-theme-contract.test.cjs | 18 ++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/.github/scripts/generated-theme-contract.cjs b/.github/scripts/generated-theme-contract.cjs index cb12622..df18f6e 100644 --- a/.github/scripts/generated-theme-contract.cjs +++ b/.github/scripts/generated-theme-contract.cjs @@ -464,6 +464,7 @@ function validateScriptReferences(validator, contract, scripts) { function validateMarkdownReferences(validator, contract, entries) { const { themeDir, themeLabel } = contract; const themeRoot = path.resolve(themeDir); + const realThemeRoot = fs.realpathSync(themeRoot); for (const entry of entries) { if (!entry.file || !entry.relativePath.endsWith('.md')) { @@ -497,6 +498,15 @@ function validateMarkdownReferences(validator, contract, entries) { validator.addError(section, `${themeLabel} has documentation link ${expected(target)} in ${expected(entry.relativePath)} that resolves to missing local path ${expected(path.relative(themeDir, resolved))}; expected it inside the generated child theme.`, ); + continue; + } + + const realTarget = fs.realpathSync(resolved); + if (realTarget !== realThemeRoot && !realTarget.startsWith(`${realThemeRoot}${path.sep}`)) { + validator.addError( + section, + `${themeLabel} has documentation link ${expected(target)} in ${expected(entry.relativePath)} that resolves outside the generated child theme through a symbolic link; expected it inside the generated child theme.`, + ); } } } diff --git a/.github/scripts/generated-theme-contract.test.cjs b/.github/scripts/generated-theme-contract.test.cjs index 03d56f4..a7f3e75 100644 --- a/.github/scripts/generated-theme-contract.test.cjs +++ b/.github/scripts/generated-theme-contract.test.cjs @@ -257,6 +257,24 @@ test('checks npm commands in every generated guidance entry point', () => { } }); +test('rejects guidance links through dependency symlinks outside the theme', () => { + withGeneratedTheme((target) => { + fs.mkdirSync(path.join(target, 'dist')); + fs.mkdirSync(path.join(target, 'node_modules')); + fs.symlinkSync(path.join(target, 'docs'), path.join(target, 'node_modules', 'local-docs'), 'dir'); + fs.appendFileSync(path.join(target, 'AGENTS.md'), '\n[Local guide](node_modules/local-docs/development.md)\n'); + assert.deepEqual(validate(target, { checkBuiltAssets: true }).errors, []); + const outside = path.join(path.dirname(target), 'external-package'); + fs.mkdirSync(outside); + fs.writeFileSync(path.join(outside, 'outside.md'), 'Outside the theme.\n'); + fs.symlinkSync(outside, path.join(target, 'node_modules', 'external-package'), 'dir'); + fs.appendFileSync(path.join(target, 'AGENTS.md'), '\n[External package](node_modules/external-package/outside.md)\n'); + const result = validate(target, { checkBuiltAssets: true }); + assert.match(result.format(), /FAIL AI guidance/); + assert.match(result.format(), /resolves outside the generated child theme through a symbolic link/); + }); +}); + test('continues checking guidance for stale tokens and starter identity after customization', () => { withGeneratedTheme((target) => { fs.mkdirSync(path.join(target, 'dist')); From b1696579e7d42497188404cc5696b9bf9bce00d8 Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Thu, 22 Oct 2026 22:56:56 -0500 Subject: [PATCH 019/100] fix(a11y): prefix numbered pagination links with Page --- .github/scripts/fallback-navigation-smoke.php | 31 ++++++++++++++++--- templates/partials/pagination.twig | 2 +- 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/.github/scripts/fallback-navigation-smoke.php b/.github/scripts/fallback-navigation-smoke.php index 807b53a..cd87af1 100644 --- a/.github/scripts/fallback-navigation-smoke.php +++ b/.github/scripts/fallback-navigation-smoke.php @@ -24,7 +24,7 @@ // Only WordPress translation needs a stand-in outside the WordPress fixture. if ( ! function_exists( '__' ) ) { /** - * Returns an untranslated string while recording its translation domain. + * Returns fixture translations while recording their translation domain. * * @param string $text Source text. * @param string $domain Translation domain. @@ -32,7 +32,7 @@ */ function __( string $text, string $domain = 'default' ): string { $GLOBALS['emulsify_navigation_translations'][] = array( $text, $domain ); - return $text; + return $GLOBALS['emulsify_navigation_translation_overrides'][ $domain ][ $text ] ?? $text; } } @@ -113,6 +113,7 @@ function emulsify_navigation_expect( bool $condition, string $message, array &$e $checks = array( 'pagination current page' => array(), 'pagination literal ellipsis' => array(), + 'pagination page prefixes' => array(), 'pagination URL escaping' => array(), 'menu current item' => array(), ); @@ -125,7 +126,7 @@ function emulsify_navigation_expect( bool $condition, string $message, array &$e $pagination_context['pagination']['pages'][2]['link'] = $display_url( '/?s=needle&paged=5&filter=a%26b' ); $linked_current_dom = emulsify_navigation_dom( $environment->render( '@templates/partials/pagination.twig', $pagination_context ) ); $linked_current = $linked_current_dom->query( '//nav//*[@aria-current="page"]' ); -emulsify_navigation_expect( 1 === $linked_current->length && '5' === trim( $linked_current->item( 0 )->textContent ), 'An explicitly current item must be marked even when it supplies a link.', $checks['pagination current page'] ); +emulsify_navigation_expect( 1 === $linked_current->length && '5' === trim( $linked_current->item( 0 )->lastChild->textContent ), 'An explicitly current item must be marked even when it supplies a link.', $checks['pagination current page'] ); $dots = $pager_dom->query( '//nav//*[contains(concat(" ", normalize-space(@class), " "), " dots ")]' ); emulsify_navigation_expect( 1 === $dots->length && 'span' === $dots->item( 0 )->nodeName && '…' === trim( $dots->item( 0 )->textContent ), 'Timber ellipsis items must render as non-interactive spans with the literal … character.', $checks['pagination literal ellipsis'] ); @@ -135,10 +136,30 @@ function emulsify_navigation_expect( bool $condition, string $message, array &$e $links = $pager_dom->query( '//nav//a' ); emulsify_navigation_expect( count( $urls ) === $links->length, 'Previous, numeric, and next pagination links must all render.', $checks['pagination URL escaping'] ); -foreach ( $links as $link ) { +$numbered_links = $pager_dom->query( '//nav//a[contains(concat(" ", normalize-space(@class), " "), " page-number ")]' ); +$prefix_query = './span[contains(concat(" ", normalize-space(@class), " "), " screen-reader-text ")]'; +emulsify_navigation_expect( 2 === $numbered_links->length, 'The fixture must contain two numbered pagination links.', $checks['pagination page prefixes'] ); +foreach ( $numbered_links as $link ) { + $prefixes = $pager_dom->query( $prefix_query, $link ); + emulsify_navigation_expect( 1 === $prefixes->length && 'Page ' === $prefixes->item( 0 )->textContent, 'Each numbered pagination link must contain one screen-reader-text span with Page and a trailing space.', $checks['pagination page prefixes'] ); +} +$linked_prefixes = $linked_current_dom->query( $prefix_query, $linked_current->item( 0 ) ); +emulsify_navigation_expect( 1 === $linked_prefixes->length && 'Page ' === $linked_prefixes->item( 0 )->textContent, 'A current page supplied as a numbered link must retain the Page prefix.', $checks['pagination page prefixes'] ); +emulsify_navigation_expect( 2 === $pager_dom->query( '//nav//span[contains(concat(" ", normalize-space(@class), " "), " screen-reader-text ")]' )->length, 'Previous, next, unlinked current, and ellipsis items must not acquire Page prefix spans.', $checks['pagination page prefixes'] ); +emulsify_navigation_expect( in_array( array( 'Page', 'emulsify' ), $GLOBALS['emulsify_navigation_translations'] ?? array(), true ), 'The Page prefix must use the parent theme translation domain.', $checks['pagination page prefixes'] ); + +$GLOBALS['emulsify_navigation_translation_overrides']['emulsify']['Page'] = $hostile; +$translated_pagination = $environment->render( '@templates/partials/pagination.twig', $pagination_context ); +$translated_dom = emulsify_navigation_dom( $translated_pagination ); +$translated_prefixes = $translated_dom->query( '//nav//span[contains(concat(" ", normalize-space(@class), " "), " screen-reader-text ")]' ); +emulsify_navigation_expect( $translated_prefixes->length > 0 && $hostile . ' ' === $translated_prefixes->item( 0 )->textContent && 0 === $translated_dom->query( '//script' )->length, 'Translated Page prefixes must remain HTML escaped and retain their separating space.', $checks['pagination page prefixes'] ); +unset( $GLOBALS['emulsify_navigation_translation_overrides'] ); + +$expected_urls = array_values( $urls ); +foreach ( $links as $index => $link ) { $title = trim( $link->textContent ); $actual = $link->getAttribute( 'href' ); - $expected = $urls[ $title ] ?? ''; + $expected = $expected_urls[ $index ] ?? ''; parse_str( (string) parse_url( $actual, PHP_URL_QUERY ), $actual_query ); parse_str( (string) parse_url( $expected, PHP_URL_QUERY ), $expected_query ); emulsify_navigation_expect( '' !== $expected && $actual === $expected && $actual_query === $expected_query && null === parse_url( $actual, PHP_URL_FRAGMENT ), sprintf( '%s link must decode once to %s; got %s.', $title, $expected, $actual ), $checks['pagination URL escaping'] ); diff --git a/templates/partials/pagination.twig b/templates/partials/pagination.twig index 4f13542..83db842 100644 --- a/templates/partials/pagination.twig +++ b/templates/partials/pagination.twig @@ -38,7 +38,7 @@ } %} <li {{ add_attributes(page_item_attributes) }}> {% if page.link|default(null) %} - <a {{ add_attributes(page_link_attributes) }}>{{ page.title }}</a> + <a {{ add_attributes(page_link_attributes) }}><span class="screen-reader-text">{{ function('__', 'Page', 'emulsify') }} </span>{{ page.title }}</a> {% else %} <span {{ add_attributes(page_link_attributes) }}>{{ page.class|default('') == 'dots' ? function('__', '…', 'emulsify') : page.title }}</span> {% endif %} From 925b667dcf09479e58ec3808b842f423debb6d01 Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Thu, 22 Oct 2026 23:00:55 -0500 Subject: [PATCH 020/100] fix(a11y): preserve language attributes and inherit link colors --- .github/scripts/template-extension-smoke.php | 9 +++++++-- templates/layouts/base.twig | 5 +++-- theme.json | 3 --- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/.github/scripts/template-extension-smoke.php b/.github/scripts/template-extension-smoke.php index adfcfb7..fe3b6cc 100644 --- a/.github/scripts/template-extension-smoke.php +++ b/.github/scripts/template-extension-smoke.php @@ -88,7 +88,7 @@ function emulsify_extension_context( bool $has_title, bool $has_menu, bool $has_ 'url' => 'https://example.test/?first=1&second=2', 'name' => 'Fixture <site> & WordPress', 'charset' => 'UTF-8', - 'language_attributes' => new \Twig\Markup( 'lang="en" dir="ltr"', 'UTF-8' ), + 'language_attributes' => 'lang="en" dir="ltr"', ), 'body_class' => 'fixture-body route-page', 'menu' => $has_menu ? array( 'get_items' => $items ) : null, @@ -201,6 +201,11 @@ static function ( $date, $format = null, $timezone = null ) use ( $core ): strin } $errors = array(); + $rtl_context = emulsify_extension_context( true, true, true ); + $rtl_context['site']['language_attributes'] = 'lang="ar" dir="rtl"'; + $rtl_output = $environment->render( 'content-fixture.twig', $rtl_context ); + emulsify_extension_expect( false !== strpos( $rtl_output, '<html lang="ar" dir="rtl">' ), 'Plain WordPress language attributes must preserve the language and RTL direction without escaping their quotes.', $errors ); + emulsify_extension_expect( false !== strpos( $rtl_output, 'Fixture <site> & WordPress' ) && false !== strpos( $rtl_output, 'Page <title> & fixture' ), 'Trusting the core language attribute fragment must preserve autoescaping for site names and page titles.', $errors ); $page_expected = file_get_contents( __DIR__ . '/../fixtures/template-extension/page-post.html' ); foreach ( array( 'head', 'header', 'content', 'footer', 'site_header', 'main', 'page_title' ) as $block ) { $name = 'child-page-' . $block . '.twig'; @@ -250,7 +255,7 @@ static function ( $date, $format = null, $timezone = null ) use ( $core ): strin if ( $errors ) { throw new RuntimeException( implode( "\n", $errors ) ); } - echo "PASS child page inheritance, seven block names, replacement boundaries, nested blocks, and structural WordPress hooks.\n"; + echo "PASS plain language attributes, RTL, other field autoescaping, child page inheritance, seven block names, replacement boundaries, nested blocks, and structural WordPress hooks.\n"; } catch ( Throwable $throwable ) { fwrite( STDERR, $throwable->getMessage() . "\n" ); $exit_code = 1; diff --git a/templates/layouts/base.twig b/templates/layouts/base.twig index 1acb433..11186a5 100644 --- a/templates/layouts/base.twig +++ b/templates/layouts/base.twig @@ -1,5 +1,6 @@ {# Shared document shell. Keep structural WordPress hooks here so child page - templates can focus on content blocks and component composition. #} + templates can focus on content blocks and component composition. + Timber supplies language_attributes as the trusted WordPress core attribute fragment. #} {% set body_attributes = { class: body_class|default('') } %} @@ -36,7 +37,7 @@ } %} {% set menu_items = menu|default(null) ? menu.get_items : [] %} <!doctype html> -<html {{ site.language_attributes|default('') }}> +<html {{ site.language_attributes|default('')|raw }}> {% block head %} {% include '@templates/partials/head.twig' %} {% endblock %} diff --git a/theme.json b/theme.json index 343e32c..a224f58 100644 --- a/theme.json +++ b/theme.json @@ -523,9 +523,6 @@ "elements": { "link": { "border": {}, - "color": { - "text": "var(--wp--preset--color--brand-primary-light)" - }, ":visited": { "color": { "text": "var(--wp--preset--color--brand-primary-dark)" From 5caab3193ff8659046560120b211b1f1add102eb Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Thu, 22 Oct 2026 23:03:15 -0500 Subject: [PATCH 021/100] fix(a11y): render pager prefix attributes through the theme helper --- templates/partials/pagination.twig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/partials/pagination.twig b/templates/partials/pagination.twig index 83db842..6c06b33 100644 --- a/templates/partials/pagination.twig +++ b/templates/partials/pagination.twig @@ -38,7 +38,7 @@ } %} <li {{ add_attributes(page_item_attributes) }}> {% if page.link|default(null) %} - <a {{ add_attributes(page_link_attributes) }}><span class="screen-reader-text">{{ function('__', 'Page', 'emulsify') }} </span>{{ page.title }}</a> + <a {{ add_attributes(page_link_attributes) }}><span {{ add_attributes({ class: 'screen-reader-text' }) }}>{{ function('__', 'Page', 'emulsify') }} </span>{{ page.title }}</a> {% else %} <span {{ add_attributes(page_link_attributes) }}>{{ page.class|default('') == 'dots' ? function('__', '…', 'emulsify') : page.title }}</span> {% endif %} From 72df4732afd385dee8b8abcfe3d26bca16ba0241 Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Thu, 22 Oct 2026 23:04:44 -0500 Subject: [PATCH 022/100] ci: audit rendered WordPress pages with sandboxed Chrome --- .github/scripts/release-check.cjs | 15 ++ .github/scripts/rendered-a11y.cjs | 179 ++++++++++++++++++++ .github/scripts/rendered-a11y.test.cjs | 72 ++++++++ .github/scripts/wordpress-fixture-smoke.cjs | 53 +++++- .github/workflows/theme-readiness.yml | 22 +++ .gitignore | 3 + 6 files changed, 343 insertions(+), 1 deletion(-) create mode 100644 .github/scripts/rendered-a11y.cjs create mode 100644 .github/scripts/rendered-a11y.test.cjs diff --git a/.github/scripts/release-check.cjs b/.github/scripts/release-check.cjs index 5126644..ffa8b9a 100644 --- a/.github/scripts/release-check.cjs +++ b/.github/scripts/release-check.cjs @@ -318,6 +318,8 @@ function runStaticChecks() { '.github/scripts/release-version-contract.test.cjs', '.github/scripts/semantic-release.test.cjs', '.github/scripts/fallback-navigation-smoke.php', + '.github/scripts/rendered-a11y.cjs', + '.github/scripts/rendered-a11y.test.cjs', '.github/scripts/docs-command-check.cjs', '.github/scripts/pr-validation.cjs', '.github/scripts/semantic-release.cjs', @@ -1064,6 +1066,19 @@ function runStaticChecks() { return 'Seven public layout blocks retain ten golden outputs, tested child inheritance, and documented boundaries.'; }); + runStaticCheck('Rendered WordPress accessibility', () => { + const { wcagTags, disabledRules } = require('./rendered-a11y.cjs'); + ensure(JSON.stringify(wcagTags) === JSON.stringify(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa']), 'Rendered WordPress axe audit must select all five WCAG tags.'); + ensure(JSON.stringify(disabledRules) === JSON.stringify(['target-size']), 'Only target-size may be disabled for the unstyled parent fixture.'); + const fixtureJob = themeReadinessWorkflow.slice(themeReadinessWorkflow.indexOf(' wordpress-fixture:'), themeReadinessWorkflow.indexOf(' extended-whisk:')); + ensure(fixtureJob.includes('Select and verify runner Chrome') && fixtureJob.includes('--headless --dump-dom about:blank'), 'WordPress fixture job must verify sandboxed runner Chrome.'); + ensure(fixtureJob.includes("WP_SMOKE_A11Y: '1'") && fixtureJob.includes('WP_SMOKE_A11Y_OUTPUT:'), 'WordPress fixture job must enable the rendered browser audit and retain its reports.'); + ensure(fixtureJob.includes('wordpress-rendered-a11y-') && fixtureJob.includes('if: always()'), 'WordPress accessibility reports must upload on success and failure.'); + ensure(wordpressFixtureSmoke.includes('Verify rendered accessibility failure gates'), 'WordPress fixture must exercise the browser audit negative controls.'); + ensure(wordpressFixtureSmoke.includes('archiveCount < 12') && wordpressFixtureSmoke.includes("['option', 'update', 'posts_per_page', '1']"), 'Rendered archive audit needs at least 12 posts and one post per page.'); + return 'Six rendered routes use sandboxed Chrome, full WCAG tags, strict navigation assertions, and retained HTML/axe reports; only target-size is excluded.'; + }); + runStaticCheck('Generated child theme documentation', () => { const cli = readFile('includes/Cli/GenerateChildThemeCommand.php'); const initHook = readFile('whisk/.cli/init.js'); diff --git a/.github/scripts/rendered-a11y.cjs b/.github/scripts/rendered-a11y.cjs new file mode 100644 index 0000000..6058017 --- /dev/null +++ b/.github/scripts/rendered-a11y.cjs @@ -0,0 +1,179 @@ +#!/usr/bin/env node + +// Audit rendered WordPress pages with the generated theme's existing Core +// dependencies. Project styling owns target size; every other tagged rule runs. +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { createRequire } = require('node:module'); + +const wcagTags = ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa']; +const disabledRules = ['target-size']; + +function tooling(themeDir) { + const requireTheme = createRequire(path.join(path.resolve(themeDir), 'package.json')); + const requireCore = createRequire(requireTheme.resolve('@emulsify/core/package.json')); + // Puppeteer is Pa11y's dependency, including when npm nests it under Pa11y. + const requirePa11y = createRequire(requireCore.resolve('pa11y')); + return { + puppeteer: requirePa11y('puppeteer'), + axePath: requireCore.resolve('axe-core/axe.min.js'), + }; +} + +function validateRoutes(routes) { + assert(Array.isArray(routes) && routes.length > 0, 'Provide at least one rendered accessibility route.'); + const names = new Set(); + for (const route of routes) { + assert(/^[a-z0-9][a-z0-9_-]*$/i.test(route.name), 'Each route needs a filename-safe name.'); + assert(!names.has(route.name), `Duplicate accessibility route name: ${route.name}`); + names.add(route.name); + assert(['http:', 'https:'].includes(new URL(route.url).protocol), `${route.name}: expected an HTTP URL.`); + const texts = Array.isArray(route.expectedText) ? route.expectedText : [route.expectedText]; + assert(texts.length > 0 && texts.every((text) => typeof text === 'string' && text.trim()), `${route.name}: provide expected rendered content.`); + } +} + +async function renderedEvidence(page, selector) { + return page.evaluate((contentSelector) => { + const currentItems = (container) => Array.from(container?.querySelectorAll('[aria-current="page"]') || [], (element) => ({ + text: element.textContent.trim(), + href: element.href || null, + tag: element.tagName.toLowerCase(), + })); + const styles = (element) => { + const computed = getComputedStyle(element); + const rect = element.getBoundingClientRect(); + return { + text: element.textContent.trim(), + className: element.className, + position: computed.position, + display: computed.display, + visibility: computed.visibility, + opacity: computed.opacity, + width: computed.width, + height: computed.height, + overflow: computed.overflow, + clip: computed.clip, + clipPath: computed.clipPath, + whiteSpace: computed.whiteSpace, + rect: { width: rect.width, height: rect.height }, + }; + }; + const pagination = document.querySelector('nav[aria-label="Pagination"]'); + return { + selector: contentSelector, + content: document.querySelector(contentSelector)?.textContent ?? null, + pagination: { + exists: Boolean(pagination), + current: currentItems(pagination), + dots: Array.from(pagination?.querySelectorAll('.dots') || [], (element) => ({ + text: element.textContent.trim(), + interactive: element.matches('a, button, input, select, textarea, [href], [role="button"], [role="link"], [tabindex]') || Boolean(element.closest('a, button')), + ariaCurrent: element.getAttribute('aria-current'), + })), + }, + primaryMenuCurrent: currentItems(document.querySelector('nav[aria-label="Primary navigation"]')), + screenReaderText: Array.from(document.querySelectorAll('.screen-reader-text'), styles), + skipLinks: Array.from(document.querySelectorAll('a[href="#content"]'), styles), + stylesheets: Array.from(document.styleSheets, (sheet) => { + let ruleCount = null; + try { ruleCount = sheet.cssRules.length; } catch {} + return { href: sheet.href, disabled: sheet.disabled, media: sheet.media.mediaText, ruleCount }; + }), + }; + }, selector); +} + +function assertEvidence(route, report) { + const evidence = report.rendered; + assert.notEqual(evidence.content, null, `${route.name}: missing content selector ${evidence.selector}.`); + const normalized = evidence.content.replace(/\s+/g, ' '); + for (const text of Array.isArray(route.expectedText) ? route.expectedText : [route.expectedText]) { + assert(normalized.includes(text.replace(/\s+/g, ' ')), `${route.name}: missing expected rendered content ${JSON.stringify(text)}.`); + } + if (route.paginationPage !== undefined) { + assert(evidence.pagination.exists, `${route.name}: missing pagination navigation.`); + assert.equal(evidence.pagination.current.length, 1, `${route.name}: expected exactly one current pagination item.`); + assert.equal(evidence.pagination.current[0].text, String(route.paginationPage), `${route.name}: pagination must mark page ${route.paginationPage}.`); + assert(evidence.pagination.dots.length > 0, `${route.name}: long pagination must contain an ellipsis.`); + for (const dot of evidence.pagination.dots) { + assert(dot.text === '…' && !dot.interactive && dot.ariaCurrent === null, `${route.name}: pagination ellipsis must be literal, noninteractive text without aria-current.`); + } + } + if (route.currentMenuHref) { + assert.equal(evidence.primaryMenuCurrent.length, 1, `${route.name}: expected exactly one current primary-menu item.`); + assert.equal(evidence.primaryMenuCurrent[0].tag, 'a', `${route.name}: current primary-menu item must be a link.`); + assert.equal(evidence.primaryMenuCurrent[0].href, new URL(route.currentMenuHref, route.url).href, `${route.name}: primary menu must mark the expected item.`); + } + assert.equal(report.browserErrors.length, 0, `${route.name}: browser errors: ${report.browserErrors.join('; ')}`); + assert.equal(report.axe.violations.length, 0, `${route.name}: accessibility violations: ${report.axe.violations.map(({ id }) => id).join(', ')}`); +} + +async function auditRenderedPages({ themeDir, routes, outputDir }) { + validateRoutes(routes); + assert(outputDir, 'Provide an accessibility artifact output directory.'); + fs.mkdirSync(outputDir, { recursive: true }); + const { puppeteer, axePath } = tooling(themeDir); + const browser = await puppeteer.launch({ headless: true }); + const reports = []; + const failures = []; + try { + for (const route of routes) { + const page = await browser.newPage(); + const report = { name: route.name, requestedUrl: route.url, wcagTags, disabledRules, browserErrors: [], axe: null }; + let html = ''; + reports.push(report); + page.on('pageerror', (error) => report.browserErrors.push(error.message)); + try { + await page.setViewport({ width: 1280, height: 900 }); + const response = await page.goto(route.url, { waitUntil: 'networkidle0', timeout: 30000 }); + html = await page.content(); + report.status = response?.status() ?? null; + report.finalUrl = page.url(); + assert(response?.ok(), `${route.name}: navigation returned HTTP ${report.status ?? 'no response'}.`); + report.rendered = await renderedEvidence(page, route.selector || 'main'); + await page.addScriptTag({ path: axePath }); + report.axe = await page.evaluate((tags) => axe.run(document, { + runOnly: { type: 'tag', values: tags }, + rules: { 'target-size': { enabled: false } }, + }), wcagTags); + for (const violation of report.axe.violations) { + console.error(`${route.name}: ${violation.id} (${violation.impact}) ${violation.helpUrl}`); + for (const node of violation.nodes) console.error(` ${node.target.join(' ')}: ${node.failureSummary}`); + } + assertEvidence(route, report); + console.log(`${route.name}: ${report.axe.passes.length} rules passed, 0 violations, ${report.axe.incomplete.length} manual review items.`); + } catch (cause) { + report.error = cause.message; + failures.push(new Error(`${route.name}: ${cause.message}`, { cause })); + } finally { + if (!html) { + try { html = await page.content(); } catch (error) { report.snapshotError = error.message; } + } + fs.writeFileSync(path.join(outputDir, `${route.name}.html`), html); + fs.writeFileSync(path.join(outputDir, `${route.name}.axe.json`), `${JSON.stringify(report, null, 2)}\n`); + await page.close(); + } + } + } finally { + await browser.close(); + } + if (failures.length) throw new AggregateError(failures, `Rendered accessibility checks failed for ${failures.length} route(s). See ${outputDir}.`); + return reports; +} + +if (require.main === module) { + const [themeDir, routesFile, outputDir] = process.argv.slice(2); + if (!themeDir || !routesFile || !outputDir) { + console.error('Usage: rendered-a11y.cjs <generated-theme-dir> <routes.json> <output-dir>'); + process.exitCode = 1; + } else { + auditRenderedPages({ themeDir, routes: JSON.parse(fs.readFileSync(routesFile, 'utf8')), outputDir }).catch((error) => { + console.error(error); + process.exitCode = 1; + }); + } +} + +module.exports = { auditRenderedPages, wcagTags, disabledRules }; diff --git a/.github/scripts/rendered-a11y.test.cjs b/.github/scripts/rendered-a11y.test.cjs new file mode 100644 index 0000000..f2150c9 --- /dev/null +++ b/.github/scripts/rendered-a11y.test.cjs @@ -0,0 +1,72 @@ +#!/usr/bin/env node + +// Browser regressions use the same installed consumer tooling and Chrome as +// the WordPress audit. Every fixture below is unstyled HTML served locally. +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const http = require('node:http'); +const os = require('node:os'); +const path = require('node:path'); +const { test } = require('node:test'); +const { auditRenderedPages, wcagTags, disabledRules } = require('./rendered-a11y.cjs'); + +test('rendered accessibility gate rejects HTTP, content, axe, pagination, and menu regressions', { timeout: 60000 }, async () => { + const themeDir = process.env.EMULSIFY_A11Y_THEME || path.resolve(__dirname, '../../whisk'); + const outputDir = fs.mkdtempSync(path.join(os.tmpdir(), 'emulsify-rendered-a11y-test-')); + const clean = `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Accessibility fixture + +
    +

    Fixture ready

    Expected content

    + +
    `; + const pages = { + '/clean': clean, + '/http-error': clean, + '/missing-content': clean.replace('Expected content', 'Different content'), + '/axe-violation': clean.replace('
    ', ''), + '/bad-pagination': clean.replace('', ''), + '/bad-menu': clean.replace('
    • { + response.writeHead(request.url === '/http-error' ? 500 : 200, { 'Content-Type': 'text/html; charset=utf-8' }); + response.end(pages[request.url] || clean); + }); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', resolve); + }); + const baseUrl = `http://127.0.0.1:${server.address().port}`; + try { + const routes = Object.keys(pages).map((url) => ({ + name: url.slice(1), + url: baseUrl + url, + expectedText: ['Fixture ready', 'Expected content'], + paginationPage: 2, + currentMenuHref: `${baseUrl}/clean`, + })); + await assert.rejects(auditRenderedPages({ themeDir, routes, outputDir }), (error) => { + assert(error instanceof AggregateError); + assert.equal(error.errors.length, 5, 'Every failing route must be reported, including routes after the first failure.'); + return true; + }); + const reports = Object.fromEntries(routes.map(({ name }) => { + assert(fs.readFileSync(path.join(outputDir, `${name}.html`), 'utf8').includes('Fixture ready')); + return [name, JSON.parse(fs.readFileSync(path.join(outputDir, `${name}.axe.json`), 'utf8'))]; + })); + assert.equal(reports.clean.error, undefined); + assert.equal(reports.clean.axe.violations.length, 0); + assert.deepEqual(reports.clean.wcagTags, wcagTags); + assert.deepEqual(disabledRules, ['target-size']); + assert.deepEqual(reports.clean.disabledRules, ['target-size']); + assert.equal(reports.clean.rendered.screenReaderText[0].position, 'static', 'Evidence must reflect actual computed styles without injecting screen-reader CSS.'); + assert.match(reports['http-error'].error, /HTTP 500/); + assert.match(reports['missing-content'].error, /missing expected rendered content/); + assert(reports['axe-violation'].axe.violations.some(({ id }) => id === 'image-alt')); + assert.match(reports['axe-violation'].error, /accessibility violations: image-alt/); + assert.match(reports['bad-pagination'].error, /exactly one current pagination item/); + assert.match(reports['bad-menu'].error, /exactly one current primary-menu item/); + } finally { + await new Promise((resolve) => server.close(resolve)); + fs.rmSync(outputDir, { recursive: true, force: true }); + } +}); diff --git a/.github/scripts/wordpress-fixture-smoke.cjs b/.github/scripts/wordpress-fixture-smoke.cjs index b359c86..37bff8f 100755 --- a/.github/scripts/wordpress-fixture-smoke.cjs +++ b/.github/scripts/wordpress-fixture-smoke.cjs @@ -17,6 +17,7 @@ const expectedGeneratedFromVersion = JSON.parse( ).version; const required = process.env.WP_SMOKE_REQUIRED === '1'; const keepFixture = process.env.WP_SMOKE_KEEP === '1'; +const auditAccessibility = process.env.WP_SMOKE_A11Y === '1'; const host = process.env.WP_SMOKE_HOST || '127.0.0.1'; const wordpressVersion = process.env.WP_SMOKE_WORDPRESS_VERSION || '6.8'; let workDir = null; @@ -223,9 +224,11 @@ function copyThemes(themesDir) { if ([ '.git', '.github', + '.artifacts', '.coverage', '.out', '.publish', + 'dist-artifact', 'node_modules', 'vendor', ].includes(first)) { @@ -630,6 +633,7 @@ for ( $number = 1; $number <= 11; $number++ ) { 'post_title' => sprintf( 'PaginationProbe result %02d', $number ), 'post_content' => sprintf( 'PaginationProbe content %02d', $number ), 'post_date' => sprintf( '2001-01-%02d 12:00:00', $number ), + 'post_category' => array( ${Number(categoryId)} ), ), true ); if ( is_wp_error( $result ) ) { fwrite( STDERR, $result->get_error_message() . "\n" ); @@ -638,6 +642,12 @@ for ( $number = 1; $number <= 11; $number++ ) { } `]); + const menuId = wp(wpPath, ['menu', 'create', 'Smoke Primary', '--porcelain']); + wp(wpPath, ['menu', 'item', 'add-post', menuId, pageId]); + wp(wpPath, ['menu', 'item', 'add-post', menuId, postId]); + wp(wpPath, ['menu', 'item', 'add-term', menuId, 'category', categoryId]); + wp(wpPath, ['menu', 'location', 'assign', menuId, 'primary']); + return { authorId, categoryId, pageId, postId }; } @@ -866,6 +876,47 @@ async function renderRoutes(wpPath, content, baseUrl, port, themeSlug) { ); await checkSearchPagination(baseUrl); await checkGeneratedAssets(baseUrl, themeSlug); + + if (auditAccessibility) { + const archiveCount = Number(wp(wpPath, ['post', 'list', '--post_type=post', '--post_status=publish', `--cat=${content.categoryId}`, '--format=count'])); + if (!Number.isInteger(archiveCount) || archiveCount < 12) { + throw new Error(`Rendered accessibility archive needs at least 12 published posts; found ${archiveCount}.`); + } + wp(wpPath, ['option', 'update', 'posts_per_page', '1']); + const themeDir = path.join(wpPath, 'wp-content', 'themes', themeSlug); + run('Install generated child accessibility dependencies', 'npm', [ + 'install', '--ignore-scripts', '--package-lock=false', + ], { + cwd: themeDir, + env: { ...process.env, PUPPETEER_SKIP_DOWNLOAD: 'true' }, + }); + run('Verify rendered accessibility failure gates', 'node', [ + '--test', path.join(repoRoot, '.github/scripts/rendered-a11y.test.cjs'), + ], { env: { ...process.env, EMULSIFY_A11Y_THEME: themeDir } }); + const { auditRenderedPages } = require('./rendered-a11y.cjs'); + const outputDir = path.resolve(process.env.WP_SMOKE_A11Y_OUTPUT || path.join(repoRoot, '.artifacts', 'wordpress-a11y')); + const pageUrl = `${baseUrl}/?page_id=${content.pageId}`; + const singleUrl = `${baseUrl}/?p=${content.postId}`; + const archiveUrl = `${baseUrl}/?cat=${content.categoryId}`; + const routes = [ + { name: 'home', url: `${baseUrl}/`, expectedText: 'Smoke Post' }, + { name: 'page', url: pageUrl, expectedText: ['Smoke Page', 'Smoke page content'], currentMenuHref: pageUrl }, + { name: 'single', url: singleUrl, expectedText: ['Smoke Post', 'Smoke post content'], currentMenuHref: singleUrl }, + { name: 'archive', url: archiveUrl, expectedText: ['Smoke Category', 'Smoke Post'], currentMenuHref: archiveUrl }, + { name: 'search', url: `${baseUrl}/?s=Smoke`, expectedText: ['Search results for Smoke', 'Smoke Page'] }, + { + name: 'archive-page-2', + url: `${archiveUrl}&paged=2`, + expectedText: ['Smoke Category', 'PaginationProbe result 11'], + paginationPage: 2, + currentMenuHref: archiveUrl, + }, + ]; + fs.mkdirSync(outputDir, { recursive: true }); + fs.writeFileSync(path.join(outputDir, 'routes.json'), `${JSON.stringify(routes, null, 2)}\n`); + await auditRenderedPages({ themeDir, outputDir, routes }); + log(`Rendered accessibility reports: ${outputDir}`); + } } function assertCleanDebugLog(wpPath) { @@ -985,7 +1036,7 @@ async function main() { stopServer(); assertCleanDebugLog(wpPath); - log('Generated and activated a child without Twig templates, checked block discovery, loaded assets, rendered parent page fallbacks and child block extensions without changing surrounding bytes, rendered home, single, archive, search, author, and 404 routes, and followed search pagination to page 2 with a clean debug log.'); + log('Generated and activated a child without Twig templates, checked block discovery, loaded assets, rendered parent page fallbacks and child block extensions without changing surrounding bytes, rendered home, single, archive, search, author, and 404 routes, and followed search pagination to page 2 with a clean debug log.' + (auditAccessibility ? ' Six rendered routes passed the WCAG accessibility audit.' : '')); dropDatabase(env); if (!keepFixture) { diff --git a/.github/workflows/theme-readiness.yml b/.github/workflows/theme-readiness.yml index d42135c..e5e3895 100644 --- a/.github/workflows/theme-readiness.yml +++ b/.github/workflows/theme-readiness.yml @@ -202,6 +202,16 @@ jobs: node-version-file: .nvmrc cache: npm + - name: Select and verify runner Chrome + timeout-minutes: 1 + run: | + # Ubuntu's AppArmor profile permits the installed Google Chrome. + # Verify it launches with its sandbox before Puppeteer runs. + chrome_path="$(command -v google-chrome)" + "$chrome_path" --version + "$chrome_path" --headless --dump-dom about:blank + printf 'PUPPETEER_EXECUTABLE_PATH=%s\n' "$chrome_path" >> "$GITHUB_ENV" + - name: Setup PHP uses: shivammathur/setup-php@v2 with: @@ -222,6 +232,8 @@ jobs: - name: Run WordPress fixture smoke env: WP_SMOKE_REQUIRED: '1' + WP_SMOKE_A11Y: '1' + WP_SMOKE_A11Y_OUTPUT: .artifacts/wordpress-a11y WP_SMOKE_THEME_ARCHIVE: dist-artifact/emulsify.zip WP_SMOKE_WORDPRESS_VERSION: ${{ matrix.wordpress-version }} WP_SMOKE_DB_HOST: 127.0.0.1 @@ -231,6 +243,16 @@ jobs: WP_SMOKE_DB_PASSWORD: root run: npm run release:check + - name: Upload rendered WordPress accessibility reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: wordpress-rendered-a11y-${{ matrix.wordpress-version }} + path: .artifacts/wordpress-a11y/ + include-hidden-files: true + if-no-files-found: ignore + retention-days: 7 + - name: Upload fixture diagnostics if: failure() uses: actions/upload-artifact@v4 diff --git a/.gitignore b/.gitignore index efcae5e..e06c20a 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,9 @@ vendor # Storybook .out +# Rendered WordPress accessibility reports. +/.artifacts/ + # Local AI/release working notes /*-PROMPTS.md /PROMPTS-*.md From eda8cca6054f9bb86491a827ffb182ecaa94b56b Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Thu, 22 Oct 2026 23:06:25 -0500 Subject: [PATCH 023/100] docs: explain rendered accessibility audits and project styling --- .github/scripts/release-check.cjs | 3 + README.md | 5 +- UPGRADE.md | 20 +++++ docs/README.md | 3 +- docs/accessibility.md | 142 ++++++++++++++++++++++++++++++ docs/release-notes-next.md | 12 +++ 6 files changed, 183 insertions(+), 2 deletions(-) create mode 100644 docs/accessibility.md diff --git a/.github/scripts/release-check.cjs b/.github/scripts/release-check.cjs index ffa8b9a..f01e2c8 100644 --- a/.github/scripts/release-check.cjs +++ b/.github/scripts/release-check.cjs @@ -335,6 +335,7 @@ function runStaticChecks() { 'UPGRADE.md', 'docs/README.md', 'docs/acf-local-json.md', + 'docs/accessibility.md', 'docs/acf-twig-blocks.md', 'docs/asset-loading.md', 'docs/block-patterns.md', @@ -1076,6 +1077,8 @@ function runStaticChecks() { ensure(fixtureJob.includes('wordpress-rendered-a11y-') && fixtureJob.includes('if: always()'), 'WordPress accessibility reports must upload on success and failure.'); ensure(wordpressFixtureSmoke.includes('Verify rendered accessibility failure gates'), 'WordPress fixture must exercise the browser audit negative controls.'); ensure(wordpressFixtureSmoke.includes('archiveCount < 12') && wordpressFixtureSmoke.includes("['option', 'update', 'posts_per_page', '1']"), 'Rendered archive audit needs at least 12 posts and one post per page.'); + const guide = readFile('docs/accessibility.md'); + ensure(guide.includes('target-size') && guide.includes('screen-reader-text') && guide.includes('focus'), 'Accessibility guide must document the sole rule exception and project styling responsibilities.'); return 'Six rendered routes use sandboxed Chrome, full WCAG tags, strict navigation assertions, and retained HTML/axe reports; only target-size is excluded.'; }); diff --git a/README.md b/README.md index 090cfa8..ab920a2 100644 --- a/README.md +++ b/README.md @@ -157,7 +157,9 @@ The `.husky/pre-commit` hook already runs `npm run lint`, which delegates to thi | `npm run build:dist` | Build the installable `dist-artifact/emulsify.zip` release archive. | | `npm run publish-test -- --no-ci` | Run a local semantic-release dry run. | -Every pull request to the configured branches runs two visible fast readiness jobs: practical smoke checks and dedicated PHPCS/PHPStan analysis. Pull requests targeting `main` or `release-2.x` additionally run the MySQL-backed WordPress fixture and Whisk Storybook accessibility audit. The WordPress job builds and extracts `emulsify.zip`, confirms its packaged generator and Whisk source, and sets `WP_SMOKE_REQUIRED=1`, so missing WP-CLI/MySQL prerequisites or route render failures fail the job instead of producing a skip. The Whisk job installs Chrome explicitly, copies an accessible CI-only story and Vite entry into the otherwise component-agnostic starter, and then builds Storybook and runs axe. +Every pull request to the configured branches runs two visible fast readiness jobs: practical smoke checks and dedicated PHPCS/PHPStan analysis. Pull requests targeting `main` or `release-2.x` additionally run the MySQL-backed WordPress fixture and Whisk Storybook accessibility audit. The WordPress job builds and extracts `emulsify.zip`, confirms its packaged generator and Whisk source, and sets `WP_SMOKE_REQUIRED=1`, so missing WP-CLI/MySQL prerequisites or route render failures fail the job instead of producing a skip. It also audits six actual WordPress routes with explicit WCAG 2.2 AA axe tags and saves rendered HTML, results, and navigation evidence. The sole `target-size` exclusion reflects the unstyled parent; projects own target sizing and focus/helper styles. See [accessibility checks](docs/accessibility.md). + +The Whisk job selects and verifies the runner-installed Chrome with its sandbox enabled, copies an accessible CI-only story and Vite entry into the otherwise component-agnostic starter, runs seeded project tests, and then builds Storybook and runs axe. Storybook coverage supplements the actual WordPress route audit. Weekly scheduled runs repeat the WordPress fixture and Whisk accessibility audit. Manual GitHub Actions > `WordPress Theme Readiness` runs can select either extended fixture with the `wordpress_fixture` and `extended_checks` inputs. Local `release:check` still skips the database fixture when prerequisites are unavailable unless `WP_SMOKE_REQUIRED=1` is set. Release publishing also requires the full WordPress fixture path before semantic-release can publish. @@ -190,6 +192,7 @@ For Emulsify CLI integration, the standalone starter repository is `https://gith - [Generated child theme contract](docs/generated-child-theme-contract.md) - [Parent and child theme architecture](docs/parent-child-architecture.md) - [Timber and Twig authoring](docs/timber-and-twig-authoring.md) +- [Accessibility checks and project responsibilities](docs/accessibility.md) - [Emulsify Core 4 and Vite workflow](docs/core-4-vite-workflow.md) - [Design token integration](docs/design-token-integration.md) - [Component recipes](docs/component-recipes.md) diff --git a/UPGRADE.md b/UPGRADE.md index bfc5062..6e1e7f0 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -111,6 +111,18 @@ fixture, focused links keep their normal color instead of inheriting the surrounding text color. Review child theme link-state styles if they relied on that inheritance. +The normal link color rule has also been removed from parent `theme.json` +because its pale blue failed contrast on white. Normal anchor colors now fall +through to WordPress core, browser, or project styles. The visited-link rule +and all other styles and presets are unchanged by this additional removal. +Review normal, visited, and focused links against the project's backgrounds. + +The document shell now prints Timber's trusted WordPress language-attribute +fragment as markup, allowing the browser to parse `lang` and any `dir` +attribute correctly. Child base-layout overrides should apply the same narrow +treatment to `site.language_attributes` while retaining autoescaping for other +values. + Project component references now resolve components inside group folders and accept both `project_machine_name:component` and `@project_machine_name/component`. Resolution follows Emulsify Core, so a flat @@ -125,6 +137,14 @@ themes that override `templates/partials/pagination.twig` should adopt these markup corrections and decode Timber's display-escaped URLs before passing them to `add_attributes`. The parent adds no frontend CSS. +Numbered pagination links now prefix their number with a translated `Page ` +inside `span.screen-reader-text`. Adopt this label in child pagination overrides; +keep the current-page span, ellipsis, and Previous/Next labels unchanged. Verify +your project loads a compatible visually hidden helper and visible focus styles. +Projects also own pointer target sizing; the parent audit's sole `target-size` +exception does not establish SC 2.5.8 compliance. See +[accessibility responsibilities](docs/accessibility.md#project-responsibilities). + Child themes overriding `templates/partials/menu.twig` should also mark only links whose `item.current` flag is true with `aria-current="page"`. diff --git a/docs/README.md b/docs/README.md index e033b2e..add9c7b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -12,6 +12,7 @@ - [Timber and Twig authoring](timber-and-twig-authoring.md) - [Parent Twig extension points](template-extension.md) +- [Accessibility checks and project responsibilities](accessibility.md) - [Component recipes](component-recipes.md) - [Emulsify Core 4 and Vite workflow](core-4-vite-workflow.md) - [Design token integration](design-token-integration.md) @@ -36,4 +37,4 @@ ## Continuous integration -Every configured pull request runs practical smoke coverage and a dedicated PHPCS/PHPStan job. Pull requests targeting `main` or `release-2.x` additionally run the required MySQL/WP-CLI WordPress rendering fixture and a CI-seeded Whisk Storybook accessibility audit. Weekly scheduled runs repeat both extended fixtures. Manual readiness runs use the `wordpress_fixture` and `extended_checks` inputs to select those extended jobs. The release process documents commands, route coverage, and merge-gate details. +Every configured pull request runs practical smoke coverage and a dedicated PHPCS/PHPStan job. Pull requests targeting `main` or `release-2.x` additionally run the required MySQL/WP-CLI WordPress rendering fixture and a CI-seeded Whisk Storybook accessibility audit. The WordPress job also audits six rendered routes with explicit WCAG 2.2 AA tags and retains per-route evidence; see [accessibility checks](accessibility.md) for the sole target-size exception and project responsibilities. Weekly scheduled runs repeat both extended fixtures. Manual readiness runs use the `wordpress_fixture` and `extended_checks` inputs to select those extended jobs. The release process documents commands, route coverage, and merge-gate details. diff --git a/docs/accessibility.md b/docs/accessibility.md new file mode 100644 index 0000000..9adc983 --- /dev/null +++ b/docs/accessibility.md @@ -0,0 +1,142 @@ +# Accessibility checks + +The parent theme supplies semantic fallback markup and WordPress integration. +It adds no frontend CSS. Projects own the final visual presentation, target +sizes, focus indicators, and any visually hidden text helpers their pages need. + +## Check Storybook and WordPress + +Run `npm run a11y` from a generated child theme to build and audit its Storybook +stories through Emulsify Core. The CI-seeded story exercises the component +toolchain without adding a component library to the committed starter. + +Stories do not execute WordPress route selection, Timber context, menus, ACF, +plugins, or the parent layout. The separate rendered WordPress audit checks +actual HTTP pages from a disposable generated and activated child theme. Its +Puppeteer and axe dependencies come from that child's installed Emulsify Core +dependency tree, rather than a separate root accessibility stack. + +From a maintainer checkout, install Composer and root npm dependencies, configure +the fixture's `WP_SMOKE_DB_HOST`, `WP_SMOKE_DB_PORT`, `WP_SMOKE_DB_USER`, and +`WP_SMOKE_DB_PASSWORD`, and provide a sandbox-capable Chrome: + +```sh +WP_SMOKE_REQUIRED=1 WP_SMOKE_A11Y=1 \ + PUPPETEER_EXECUTABLE_PATH="/path/to/google-chrome" \ + npm run release:check +``` + +WP-CLI, PHP with mysqli, and MySQL are required. `WP_SMOKE_A11Y=1` opts into the +browser audit; `WP_SMOKE_REQUIRED=1` prevents missing WordPress prerequisites +from producing a skip. The normal runtime fixture without the accessibility +flag does not establish browser-audit coverage. `--skip-smoke` skips the fixture +and its browser audit. See [the release process](release-process.md) for setup. + +The fixture calls the `auditRenderedPages` API exported by +`.github/scripts/rendered-a11y.cjs` while its HTTP server is live. A separate +CLI is available for an already running site: + +```text +node .github/scripts/rendered-a11y.cjs +``` + +The WordPress readiness job enables the browser audit and verifies the runner's +installed Chrome can launch with its sandbox. Do not disable Chrome's sandbox +to make an audit pass. + +## Rendered route and navigation coverage + +The fixture uses plain permalinks and audits these anonymous routes: + +| Route | Fixture request | +| --- | --- | +| Home | `/` | +| Page | `/?page_id=` | +| Single post | `/?p=` | +| Category archive | `/?cat=` | +| Search | `/?s=Smoke` | +| Category archive, page 2 | `/?cat=&paged=2` | + +During these audits, `posts_per_page` is 1 and at least 12 posts belong to the +category. The second archive page therefore exercises the actual current page, +numbered links, previous/next links, and a non-interactive ellipsis with the +fallback's `mid_size: 3` and `end_size: 2` settings. + +Navigation assertions supplement axe. The runtime fixture follows a search +pagination link and verifies its destination. The rendered audit checks +meaningful content and, on the second archive page, exactly one +`aria-current="page"` in its pager, current page 2, and a literal non-interactive +ellipsis without `aria-current`. The fixture assigns a primary menu with page, +post, and category links and checks the matching current link on those routes. +Current-state counts are scoped to each navigation region: a menu and pager +may each correctly have a current item. The fallback marks current menu items +only, leaving ancestors unmarked. + +Numbered pagination links include a translated `Page ` prefix in a +`span.screen-reader-text`, giving a link such as `Page 2` an explicit name. +Only linked page numbers receive the prefix. The current-page span, ellipsis, +and already named Previous/Next links retain their existing text and BEM +classes. This markup change adds no stylesheet. + +## Axe policy and artifacts + +The rendered audit explicitly selects these axe tags: + +```text +wcag2a, wcag2aa, wcag21a, wcag21aa, wcag22aa +``` + +This includes WCAG 2.2 AA rules rather than relying on a runner's default tag +selection. Failed HTTP responses, browser or audit execution errors, failed +render/navigation assertions, and any reported violation of the enabled rules +fail the audit. + +The sole disabled axe rule is `target-size`. This is a narrow test-policy +exception for the unstyled parent fallback: the parent does not add frontend +CSS to enlarge or space links. It is not an exemption from WCAG or a claim that +the rendered site meets Target Size (Minimum). No other selected rule is +disabled. + +Results go to `.artifacts/wordpress-a11y` by default; set +`WP_SMOKE_A11Y_OUTPUT` to choose another directory. Each route saves rendered +HTML, full axe JSON including incomplete checks, and computed-style evidence +for screen-reader text. CI retains them as +`wordpress-rendered-a11y-`, including on failure. Inspect +incomplete results manually; an automatic pass cannot resolve those checks. + +The parent document shell preserves WordPress's trusted language attributes as +markup so the browser parses `lang` correctly. Normal link colors now fall +through to WordPress core, browser, or project styles rather than the former +pale-blue theme rule that failed contrast on white. The existing visited-link +rule is retained. Projects must verify their final colors against actual +backgrounds. + +## Project responsibilities + +Projects must meet [WCAG 2.2 SC 2.5.8, Target Size (Minimum)](https://www.w3.org/WAI/WCAG22/Understanding/target-size-minimum.html): +provide pointer targets of at least 24 by 24 CSS pixels or satisfy an applicable +criterion exception, such as sufficient spacing. Audit the styled project with +`target-size` enabled; the parent fixture's exclusion must not hide project +regressions. + +Ensure `screen-reader-text` remains available to assistive technology while +being visually hidden where intended. Do not use `display: none` or +`visibility: hidden` for spoken link labels. Verify the actual loaded WordPress +and project styles: a class name alone does not hide text. Projects must supply +a compatible helper if their asset policy does not provide one. + +In the WordPress 6.8 fixture, core's loaded +`wp-includes/css/dist/block-library/style.min.css` supplies `.screen-reader-text`. +Before focus, both the skip link and pagination prefix measure 1 by 1 CSS +pixels, with absolute positioning, hidden overflow, and `clip-path: inset(50%)`. +The parent adds no helper CSS. Projects that disable the core stylesheet must +provide an equivalent accessible helper and verify its focus behavior. + +Preserve a visible keyboard focus indicator on every interactive control, as +required by [SC 2.4.7, Focus Visible](https://www.w3.org/WAI/WCAG22/Understanding/focus-visible.html). +Check the skip link reveals itself on focus and reaches `#content`, menu and +pagination operation, heading structure, zoom/reflow, contrast, and screen +reader announcements on real project content. Test anonymous and relevant +signed-in states, editor previews, and plugin integrations. Automated axe +results are useful evidence, not complete WCAG conformance or a replacement +for keyboard and assistive-technology testing. diff --git a/docs/release-notes-next.md b/docs/release-notes-next.md index b48242e..fd35764 100644 --- a/docs/release-notes-next.md +++ b/docs/release-notes-next.md @@ -33,6 +33,10 @@ Title: `2.1.0` ### Added +- A rendered WordPress accessibility audit covers six real fixture routes with + explicit WCAG 2.2 AA tags, menu/pagination assertions, and per-route HTML, + axe JSON, and screen-reader-text evidence. Its sole `target-size` exception + preserves the parent's no-frontend-CSS boundary; projects own final styling. - Generated child themes include portable `AGENTS.md` guidance, a WordPress integration guide, and an optional integration workflow skill. Both generators preserve these files unchanged, including hidden `.agents/` @@ -84,6 +88,14 @@ Title: `2.1.0` ### Fixed +- The document shell renders Timber's trusted WordPress language attributes as + markup so `lang` is valid. The failing pale-blue normal link color is removed + from parent `theme.json`; normal colors fall through to core, browser, or + project styles, while visited-link styling and all other styles and presets + are retained. +- Numbered fallback pagination links include a translated `Page ` label in a + `screen-reader-text` span. Current/ellipsis spans and Previous/Next links keep + their existing labels and classes; no frontend CSS is added. - Copied lint, lint-fix, and format scripts run every step and propagate any failure. Generated audit wrappers send documentation footers to stderr while preserving JSON stdout, arguments, and Core's exit status. From adfdce9f61179051daed2656dc3be27c8f55950e Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Fri, 23 Oct 2026 08:49:50 -0500 Subject: [PATCH 024/100] test(twig): characterize PHP and JavaScript helper output --- .github/fixtures/twig-helper-contract.json | 189 +++++++++++++++++++++ .github/scripts/docs-command-check.cjs | 8 + .github/scripts/pr-validation.cjs | 1 + .github/scripts/release-check.cjs | 13 ++ .github/scripts/twig-helper-contract.cjs | 60 +++++++ .github/scripts/twig-helper-contract.php | 77 +++++++++ docs/release-notes-next.md | 4 + docs/timber-and-twig-authoring.md | 68 ++++++++ package.json | 1 + 9 files changed, 421 insertions(+) create mode 100644 .github/fixtures/twig-helper-contract.json create mode 100644 .github/scripts/twig-helper-contract.cjs create mode 100644 .github/scripts/twig-helper-contract.php diff --git a/.github/fixtures/twig-helper-contract.json b/.github/fixtures/twig-helper-contract.json new file mode 100644 index 0000000..7a15614 --- /dev/null +++ b/.github/fixtures/twig-helper-contract.json @@ -0,0 +1,189 @@ +{ + "schemaVersion": 1, + "description": "Observed WordPress parent and Core helper behavior, including known differences. Expectations are reviewed literals; this check never rewrites them. repeat joins call output with | and records the final attributes/context.", + "reference": { + "coreVersion": "4.5.0", + "corpus": "emulsify-core/src/extensions/twig/__fixtures__/parity-v1.json", + "documentation": "emulsify-core/docs/twig-php-parity.md" + }, + "cases": [ + { + "id": "bem-block", + "helper": "bem", + "args": ["button"], + "php": {"attributes": {"class": ["button"]}, "html": "class=\"button\"", "contextAfter": {}}, + "js": {"attributes": {"class": ["button"]}, "html": "class=\"button\"", "contextAfter": {}} + }, + { + "id": "bem-element-modifiers-and-extras", + "helper": "bem", + "args": ["title", ["small", "small"], "card", ["js-click", "title"]], + "php": {"attributes": {"class": ["card__title", "card__title--small", "js-click", "title"]}, "html": "class=\"card__title card__title--small js-click title\"", "contextAfter": {}}, + "js": {"attributes": {"class": ["card__title", "card__title--small", "js-click", "title"]}, "html": "class=\"card__title card__title--small js-click title\"", "contextAfter": {}} + }, + { + "id": "bem-object-block-element", + "helper": "bem", + "args": [{"block": "card", "element": "title", "modifiers": ["small"]}], + "php": {"attributes": {"class": ["card__title", "card__title--small"]}, "html": "class=\"card__title card__title--small\"", "contextAfter": {}}, + "js": {"attributes": {"class": ["card__title", "card__title--small"]}, "html": "class=\"card__title card__title--small\"", "contextAfter": {}} + }, + { + "id": "bem-object-camelcase-aliases", + "helper": "bem", + "args": [{"baseClass": "button", "blockName": "card", "extra": ["utility"]}], + "php": {"attributes": {"class": ["card__button", "utility"]}, "html": "class=\"card__button utility\"", "contextAfter": {}}, + "js": {"attributes": {"class": ["card__button", "utility"]}, "html": "class=\"card__button utility\"", "contextAfter": {}} + }, + { + "id": "bem-object-explicit-snakecase-keys", + "helper": "bem", + "args": [{"base_class": "title", "blockname": "card", "block": "ignored", "element": "ignored", "attributes": {"data-count": 0}}], + "php": {"attributes": {"data-count": "0", "class": ["card__title"]}, "html": "data-count=\"0\" class=\"card__title\"", "contextAfter": {}}, + "js": {"attributes": {"data-count": "0", "class": ["card__title"]}, "html": "data-count=\"0\" class=\"card__title\"", "contextAfter": {}} + }, + { + "id": "bem-nested-lists-and-punctuation", + "helper": "bem", + "args": ["Ünicode card/1", ["wide space", ["nested"], "", null, false], "", ["u:wide", "2xl"]], + "php": {"attributes": {"class": ["nicode", "card-1", "card-1--wide", "space", "card-1--nested", "u-wide", "_2xl"]}, "html": "class=\"nicode card-1 card-1--wide space card-1--nested u-wide _2xl\"", "contextAfter": {}}, + "js": {"attributes": {"class": ["nicode", "card-1", "card-1--wide", "space", "card-1--nested", "u-wide", "_2xl"]}, "html": "class=\"nicode card-1 card-1--wide space card-1--nested u-wide _2xl\"", "contextAfter": {}} + }, + { + "id": "bem-boolean-and-numeric-values", + "helper": "bem", + "args": ["card", [false, 0, true, ["nested"]], "", [0, "u:wide", "2xl"]], + "php": {"attributes": {"class": ["card", "card--0", "card--1", "card--nested", "_0", "u-wide", "_2xl"]}, "html": "class=\"card card--0 card--1 card--nested _0 u-wide _2xl\"", "contextAfter": {}}, + "js": {"attributes": {"class": ["card", "card--0", "card--true", "card--nested", "u-wide", "_2xl"]}, "html": "class=\"card card--0 card--true card--nested u-wide _2xl\"", "contextAfter": {}}, + "differences": [ + {"field": "attributes", "reason": "PHP casts true modifiers to 1 and retains numeric zero extra classes; Core stringifies true and drops numeric zero class values. Both omit false modifiers and retain zero modifiers."}, + {"field": "html", "reason": "The differing boolean modifier and numeric zero class tokens remain visible in the exact serialized HTML."} + ] + }, + { + "id": "bem-array-context-and-attribute-precedence", + "helper": "bem", + "context": {"id": "context", "class": ["context", "seed"], "title": "present"}, + "args": ["button", ["wide"], "", ["utility"], {"id": "option", "class": ["seed", "button"]}], + "php": {"attributes": {"id": "context", "class": ["seed", "button", "button--wide", "utility", "context"], "title": "present"}, "html": "id=\"context\" class=\"seed button button--wide utility context\" title=\"present\"", "contextAfter": {"id": "context", "class": ["context", "seed"], "title": "present"}}, + "js": {"attributes": {"id": "context", "class": ["seed", "button", "button--wide", "utility", "context"], "title": "present"}, "html": "id=\"context\" class=\"seed button button--wide utility context\" title=\"present\"", "contextAfter": {}}, + "differences": [{"field": "contextAfter", "reason": "The parent copies caller attributes without consuming them; Core clears invocation context attributes after a helper call."}] + }, + { + "id": "bem-bag-context", + "helper": "bem", + "contextKind": "bag", + "context": {"id": "existing", "class": ["context", "button"]}, + "args": ["button", ["wide"]], + "php": {"attributes": {"class": ["button", "button--wide", "context"], "id": "existing"}, "html": "class=\"button button--wide context\" id=\"existing\"", "contextAfter": {"id": "existing", "class": ["context", "button"]}}, + "js": {"attributes": {"class": ["button", "button--wide", "context"], "id": "existing"}, "html": "class=\"button button--wide context\" id=\"existing\"", "contextAfter": {}}, + "differences": [{"field": "contextAfter", "reason": "The parent also copies its AttributeBag context without mutating the original bag; Core clears the caller's context slot."}] + }, + { + "id": "bem-empty", + "helper": "bem", + "args": ["", [], "", []], + "php": {"attributes": {}, "html": "", "contextAfter": {}}, + "js": {"attributes": {}, "html": "", "contextAfter": {}} + }, + { + "id": "add-class-merging-and-list-replacement", + "helper": "add_attributes", + "context": {"class": ["base", "shared"], "aria-describedby": ["old", "same"]}, + "args": [{"class": ["shared", "new"], "aria-describedby": ["new", "same"]}], + "php": {"attributes": {"class": ["base", "shared", "new"], "aria-describedby": ["new", "same"]}, "html": "class=\"base shared new\" aria-describedby=\"new same\"", "contextAfter": {"class": ["base", "shared"], "aria-describedby": ["old", "same"]}}, + "js": {"attributes": {"class": ["base", "shared", "new"], "aria-describedby": ["new", "same"]}, "html": "class=\"base shared new\" aria-describedby=\"new same\"", "contextAfter": {}}, + "differences": [{"field": "contextAfter", "reason": "Class merging and non-class list replacement agree, but only Core consumes caller attributes."}] + }, + { + "id": "add-booleans-aria-and-falsy-classes", + "helper": "add_attributes", + "args": [{"disabled": true, "hidden": false, "data-null": null, "aria-expanded": false, "aria-selected": true, "aria-checked": "false", "class": [true, false, 0, null, ""]}], + "php": {"attributes": {"disabled": true, "aria-selected": true, "aria-checked": "false", "class": ["_1", "_0"]}, "html": "disabled aria-selected aria-checked=\"false\" class=\"_1 _0\"", "contextAfter": {}}, + "js": {"attributes": {"disabled": true, "aria-selected": true, "aria-checked": "false", "class": ["true"]}, "html": "disabled aria-selected aria-checked=\"false\" class=\"true\"", "contextAfter": {}}, + "differences": [ + {"field": "attributes", "reason": "PHP class casts keep true as 1 and numeric zero; Core retains true as the token true and drops numeric zero. Both treat boolean ARIA input like HTML booleans; ARIA string values are the portable form."}, + {"field": "html", "reason": "The class token differences remain serialized; neither runtime silently coerces boolean ARIA input into the strings required by ARIA."} + ] + }, + { + "id": "add-boolean-overrides-and-empty-string", + "helper": "add_attributes", + "context": {"disabled": true, "aria-expanded": "true", "title": "before", "data-null": "before"}, + "args": [{"disabled": false, "aria-expanded": false, "title": "", "data-null": null}], + "php": {"attributes": {"disabled": true, "aria-expanded": "true", "title": "", "data-null": "before"}, "html": "disabled aria-expanded=\"true\" title=\"\" data-null=\"before\"", "contextAfter": {"disabled": true, "aria-expanded": "true", "title": "before", "data-null": "before"}}, + "js": {"attributes": {"disabled": true, "aria-expanded": "true", "title": "", "data-null": "before"}, "html": "disabled aria-expanded=\"true\" title=\"\" data-null=\"before\"", "contextAfter": {}}, + "differences": [{"field": "contextAfter", "reason": "Both ignore false/null replacements and preserve empty strings; WordPress retains the source context and Core consumes it."}] + }, + { + "id": "add-escaping-and-bag-context", + "helper": "add_attributes", + "contextKind": "bag", + "context": {"id": "old"}, + "args": [{"title": "A & \"B\" 'D'", "href": "/?a=1&b=2", "data-count": 0, "disabled": true}], + "php": {"attributes": {"id": "old", "title": "A & \"B\" 'D'", "href": "/?a=1&b=2", "data-count": "0", "disabled": true}, "html": "id=\"old\" title=\"A & "B" <C> 'D'\" href=\"/?a=1&b=2\" data-count=\"0\" disabled", "contextAfter": {"id": "old"}}, + "js": {"attributes": {"id": "old", "title": "A & \"B\" 'D'", "href": "/?a=1&b=2", "data-count": "0", "disabled": true}, "html": "id=\"old\" title=\"A & "B" <C> 'D'\" href=\"/?a=1&b=2\" data-count=\"0\" disabled", "contextAfter": {}}, + "differences": [{"field": "contextAfter", "reason": "The exact escaping, numeric stringification, and boolean serialization agree; only Core consumes the caller's bag context."}] + }, + { + "id": "add-invalid-names-and-nested-values", + "helper": "add_attributes", + "args": [{"bad name": "ignored", "_keys": ["ignored"], "data-good": [true, false, 0, "a&b", ["nested"]], "aria-expanded": "false", "aria-selected": "true", "class": "class=\"legacy legacy extra\""}], + "php": {"attributes": {"data-good": ["1", "0", "a&b", "nested"], "aria-expanded": "false", "aria-selected": "true", "class": ["legacy", "extra"]}, "html": "data-good=\"1 0 a&b nested\" aria-expanded=\"false\" aria-selected=\"true\" class=\"legacy extra\"", "contextAfter": {}}, + "js": {"attributes": {"data-good": ["true", "0", "a&b", "nested"], "aria-expanded": "false", "aria-selected": "true", "class": ["legacy", "extra"]}, "html": "data-good=\"true 0 a&b nested\" aria-expanded=\"false\" aria-selected=\"true\" class=\"legacy extra\"", "contextAfter": {}}, + "differences": [ + {"field": "attributes", "reason": "Inside non-class attribute lists PHP casts true to 1, while Core stringifies it to true. Invalid names, _keys, legacy class strings, and explicit ARIA strings agree."}, + {"field": "html", "reason": "The true list-item cast difference remains visible in serialized data-good."} + ] + }, + { + "id": "add-repeated-context-consumption", + "helper": "add_attributes", + "repeat": 2, + "context": {"id": "existing", "class": ["context"]}, + "args": [{"class": ["extra"]}], + "php": {"attributes": {"id": "existing", "class": ["context", "extra"]}, "html": "id=\"existing\" class=\"context extra\"|id=\"existing\" class=\"context extra\"", "contextAfter": {"id": "existing", "class": ["context"]}}, + "js": {"attributes": {"class": ["extra"]}, "html": "id=\"existing\" class=\"context extra\"|class=\"extra\"", "contextAfter": {}}, + "differences": [ + {"field": "attributes", "reason": "The second WordPress helper call still merges source context attributes, whereas Core consumed them during the first call."}, + {"field": "html", "reason": "WordPress repeats source context attributes in the second output; Core prints them only on the first call."}, + {"field": "contextAfter", "reason": "The parent preserves caller context on both calls; Core clears it after the first call."} + ] + }, + { + "id": "add-no-arguments-context-leak", + "helper": "add_attributes", + "context": {"id": "existing", "class": ["context"]}, + "contextVars": {"fixture_secret": "do-not-print"}, + "args": [], + "php": {"attributes": {"fixture_secret": "do-not-print", "attributes": ["existing", "context"]}, "html": "fixture_secret=\"do-not-print\" attributes=\"existing context\"", "contextAfter": {"id": "existing", "class": ["context"]}}, + "js": {"attributes": {"id": "existing", "class": ["context"]}, "html": "id=\"existing\" class=\"context\"", "contextAfter": {}}, + "differences": [ + {"field": "attributes", "reason": "Current parent context shifting requires a second argument, so a no-argument call mistakes the entire Twig context for additional attributes and exposes unrelated variables. This records a bug, not an approved behavior."}, + {"field": "html", "reason": "The no-argument PHP context-shifting bug serializes unrelated context values instead of the intended id/class. Core correctly merges only context attributes."}, + {"field": "contextAfter", "reason": "WordPress leaves source attributes untouched, while Core consumes them."} + ] + }, + { + "id": "bem-no-arguments-context-options-leak", + "helper": "bem", + "context": {"id": "existing", "class": ["context"]}, + "contextVars": {"block": "unrelated-block"}, + "args": [], + "php": {"attributes": {"id": "existing", "class": ["context", "unrelated-block"]}, "html": "id=\"existing\" class=\"context unrelated-block\"", "contextAfter": {"id": "existing", "class": ["context"]}}, + "js": {"attributes": {"id": "existing", "class": ["context"]}, "html": "id=\"existing\" class=\"context\"", "contextAfter": {}}, + "differences": [ + {"field": "attributes", "reason": "The current no-argument parent call mistakes the entire Twig context for BEM options, allowing an unrelated context block variable to become a class. This records a bug, not an approved behavior."}, + {"field": "html", "reason": "The context-shifting bug adds an unrelated class to no-argument bem output; Core only uses context attributes."}, + {"field": "contextAfter", "reason": "WordPress leaves source attributes untouched, while Core consumes them."} + ] + }, + { + "id": "add-empty", + "helper": "add_attributes", + "args": [{}], + "php": {"attributes": {}, "html": "", "contextAfter": {}}, + "js": {"attributes": {}, "html": "", "contextAfter": {}} + } + ] +} diff --git a/.github/scripts/docs-command-check.cjs b/.github/scripts/docs-command-check.cjs index fd6276a..cf1b73c 100644 --- a/.github/scripts/docs-command-check.cjs +++ b/.github/scripts/docs-command-check.cjs @@ -8,6 +8,14 @@ const repoRoot = path.resolve(__dirname, '../..'); // Documentation that describes the repository itself. Each entry is scoped to a // heading so unrelated prose in the same file cannot satisfy the check. const ROOT_CHECKS = [ + { + relativePath: 'docs/timber-and-twig-authoring.md', + heading: 'Helper characterization', + packagePath: 'package.json', + packageLabel: 'the maintainer checkout', + includeInlineCode: true, + expectedScripts: ['test:twig-helpers', 'whisk:install'], + }, { relativePath: 'README.md', heading: 'Working inside a generated child theme', diff --git a/.github/scripts/pr-validation.cjs b/.github/scripts/pr-validation.cjs index 869018b..915b676 100644 --- a/.github/scripts/pr-validation.cjs +++ b/.github/scripts/pr-validation.cjs @@ -48,6 +48,7 @@ run('Run fallback navigation smoke test', 'npm', ['run', 'smoke:navigation']); run('Run Twig switch smoke test', 'npm', ['run', 'smoke:twig-switch']); run('Run Twig project namespace smoke test', 'npm', ['run', 'smoke:twig-project-namespace']); run('Install Whisk dependencies', 'npm', ['run', 'whisk:install']); +run('Compare PHP and JavaScript Twig helper contracts', 'npm', ['run', 'test:twig-helpers']); run('Verify lint and format failure propagation', 'npm', ['run', 'test:lint-wrappers']); run('Run generated project Jest smoke test', 'npm', ['run', 'smoke:jest-project']); run('Run accessibility runner tests', 'npm', ['run', 'test:a11y-runner']); diff --git a/.github/scripts/release-check.cjs b/.github/scripts/release-check.cjs index f01e2c8..1e4b292 100644 --- a/.github/scripts/release-check.cjs +++ b/.github/scripts/release-check.cjs @@ -404,6 +404,9 @@ function runStaticChecks() { '.github/scripts/acf-local-json-smoke.php', '.github/scripts/asset-manifest-smoke.php', '.github/scripts/attribute-helper-smoke.php', + '.github/scripts/twig-helper-contract.cjs', + '.github/scripts/twig-helper-contract.php', + '.github/fixtures/twig-helper-contract.json', '.github/scripts/block-scoped-assets-smoke.php', '.github/scripts/bootstrap-loader-smoke.php', '.github/scripts/child-theme-generator-smoke.php', @@ -594,6 +597,16 @@ function runStaticChecks() { return 'Attribute helper runtime and smoke fixture are wired.'; }); + runStaticCheck('Twig helper characterization', () => { + ensure(rootPackage.scripts['test:twig-helpers'] === 'node .github/scripts/twig-helper-contract.cjs', 'Root package should expose the PHP/JavaScript Twig helper characterization.'); + const prValidation = readFile('.github/scripts/pr-validation.cjs'); + const install = prValidation.indexOf("run('Install Whisk dependencies'"); + const compare = prValidation.indexOf("['run', 'test:twig-helpers']"); + ensure(install >= 0 && compare > install, 'PR validation must compare both helper runtimes after installing Whisk/Core.'); + ensure(docs.twig.includes('## Helper characterization'), 'Twig authoring docs must explain the characterization and recorded differences.'); + return 'The PR gate compares exact PHP and installed Core helper output against one explicit-difference corpus.'; + }); + runStaticCheck('Twig HTML autoescaping', () => { const twig = readFile('includes/Runtime/Twig.php'); const smoke = readFile('.github/scripts/twig-autoescape-smoke.php'); diff --git a/.github/scripts/twig-helper-contract.cjs b/.github/scripts/twig-helper-contract.cjs new file mode 100644 index 0000000..a60a8f7 --- /dev/null +++ b/.github/scripts/twig-helper-contract.cjs @@ -0,0 +1,60 @@ +#!/usr/bin/env node + +// Characterization only: differing expectations require an explicit reason. +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { createRequire } = require('node:module'); +const { spawnSync } = require('node:child_process'); +const { pathToFileURL } = require('node:url'); +const { isDeepStrictEqual } = require('node:util'); + +async function main() { + assert.ok(process.argv.length <= 3, 'Usage: node twig-helper-contract.cjs [installed-theme-directory]'); + const project = path.resolve(process.argv[2] || path.join(__dirname, '../../whisk')); + const requireProject = createRequire(path.join(project, 'package.json')); + const core = requireProject('@emulsify/core/package.json'); + const version = core.version.match(/^(\d+)\.(\d+)\.(\d+)$/); + assert.ok(version && (Number(version[1]) > 4 || Number(version[1]) === 4 && Number(version[2]) >= 5), `Core ${core.version} is below the 4.5.0 helper baseline.`); + const { bemAttributes, addAttributes } = await import(pathToFileURL(requireProject.resolve('@emulsify/core/extensions/twig'))); + const corpus = JSON.parse(fs.readFileSync(path.join(__dirname, '../fixtures/twig-helper-contract.json'), 'utf8')); + assert.equal(corpus.schemaVersion, 1, 'Unsupported Twig helper fixture schema.'); + assert.ok(Array.isArray(corpus.cases) && corpus.cases.length > 0, 'Twig helper cases must be a nonempty array.'); + const ids = new Set(); + + for (const fixture of corpus.cases) { + assert.ok(fixture.id && !ids.has(fixture.id), `Duplicate or missing fixture ID: ${fixture.id}`); + ids.add(fixture.id); + assert.ok(['bem', 'add_attributes'].includes(fixture.helper), `${fixture.id}: unknown helper`); + assert.ok(Array.isArray(fixture.args), `${fixture.id}: helper arguments must be an array.`); + const differences = fixture.differences || []; + for (const field of ['attributes', 'html', 'contextAfter']) { + const notes = differences.filter((entry) => entry.field === field); + const same = isDeepStrictEqual(fixture.php[field], fixture.js[field]); + assert.equal(notes.length, same ? 0 : 1, `${fixture.id}: ${field} needs exactly one reason when different, and none when equal.`); + assert.ok(notes.every((entry) => typeof entry.reason === 'string' && entry.reason.trim()), `${fixture.id}: empty difference reason`); + } + assert.ok(differences.every((entry) => ['attributes', 'html', 'contextAfter'].includes(entry.field)), `${fixture.id}: unknown difference field`); + + const values = structuredClone(fixture.context || {}); + const invocation = { context: { ...fixture.contextVars, attributes: fixture.contextKind === 'bag' ? addAttributes(values) : values } }; + const args = fixture.args; + const html = []; + let attributes; + for (let index = 0; index < (fixture.repeat || 1); index += 1) { + attributes = fixture.helper === 'bem' + ? bemAttributes(args[0], args[1], args[2], args[3], args[4], invocation) + : addAttributes(args[0], invocation); + html.push(String(attributes)); + } + const result = { attributes: attributes.toObject(), html: html.join('|'), contextAfter: invocation.context.attributes }; + assert.deepEqual(result, fixture.js, `${fixture.id}: Core helper output changed; review the exact attributes, HTML, and remaining context.`); + } + console.log(`PASS ${corpus.cases.length} JavaScript Twig helper cases on Core ${core.version}.`); + + const php = spawnSync('php', [path.join(__dirname, 'twig-helper-contract.php')], { stdio: 'inherit' }); + if (php.error) throw php.error; + assert.equal(php.status, 0, 'PHP Twig helper contract failed.'); +} + +main().catch((error) => { console.error(error); process.exitCode = 1; }); diff --git a/.github/scripts/twig-helper-contract.php b/.github/scripts/twig-helper-contract.php new file mode 100644 index 0000000..43ba584 --- /dev/null +++ b/.github/scripts/twig-helper-contract.php @@ -0,0 +1,77 @@ +functions( array() ); + $environment = new \Twig\Environment( new \Twig\Loader\ArrayLoader(), $integration->environment_options( array( 'cache' => false ) ) ); + + foreach ( $functions as $name => $definition ) { + $environment->addFunction( new \Twig\TwigFunction( $name, $definition['callable'], $definition['options'] ?? array() ) ); + } + + foreach ( $corpus['cases'] as $fixture ) { + if ( ! is_array( $fixture['args'] ?? null ) ) { + throw new RuntimeException( $fixture['id'] . ': helper arguments must be an array.' ); + } + $values = $fixture['context'] ?? array(); + $context = array_merge( $fixture['contextVars'] ?? array(), array( 'attributes' => 'bag' === ( $fixture['contextKind'] ?? '' ) ? new AttributeBag( $values ) : $values ) ); + $args = $fixture['args']; + $html = array(); + for ( $index = 0; $index < ( $fixture['repeat'] ?? 1 ); ++$index ) { + $bag = call_user_func_array( $functions[ $fixture['helper'] ]['callable'], array_merge( array( $context ), $args ) ); + $html[] = (string) $bag; + } + $result = array( + 'attributes' => (object) $bag->toArray(), + 'html' => implode( '|', $html ), + 'contextAfter' => (object) ( $context['attributes'] instanceof AttributeBag ? $context['attributes']->toArray() : $context['attributes'] ), + ); + $expected = $fixture['php']; + $expected['attributes'] = (object) $expected['attributes']; + $expected['contextAfter'] = (object) $expected['contextAfter']; + + if ( json_encode( $result, JSON_THROW_ON_ERROR ) !== json_encode( $expected, JSON_THROW_ON_ERROR ) ) { + throw new RuntimeException( $fixture['id'] . ': PHP helper output changed: ' . json_encode( $result, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR ) ); + } + + $names = array(); + foreach ( $args as $index => $value ) { + $name = 'argument_' . $index; + $names[] = $name; + $context[ $name ] = $value; + } + $call = '{{ ' . $fixture['helper'] . '(' . implode( ', ', $names ) . ') }}'; + $template = implode( '|', array_fill( 0, $fixture['repeat'] ?? 1, $call ) ); + $html = $environment->createTemplate( $template )->render( $context ); + + if ( $expected['html'] !== $html ) { + throw new RuntimeException( $fixture['id'] . ': registered Twig rendering changed: ' . $html ); + } + } + + echo 'PASS ' . count( $corpus['cases'] ) . ' PHP Twig helper cases and registered autoescaped renders on PHP ' . PHP_VERSION . ".\n"; +} catch ( Throwable $throwable ) { + fwrite( STDERR, $throwable->getMessage() . "\n" ); + exit( 1 ); +} diff --git a/docs/release-notes-next.md b/docs/release-notes-next.md index fd35764..6a8d21c 100644 --- a/docs/release-notes-next.md +++ b/docs/release-notes-next.md @@ -129,6 +129,10 @@ Title: `2.1.0` ### Internal +- Added `npm run test:twig-helpers` to characterize PHP and installed Core + JavaScript helpers with exact HTML, attribute maps, and remaining context. + Separate expectations preserve observed differences without changing runtime + behavior or claiming universal parity. - Re-sync the standalone `emulsify-wordpress-starter` repository from `whisk/` before publishing, including its hidden `.agents/` directory and AI guidance. - Extended CI verifies the runner's installed Chrome with its sandbox enabled, diff --git a/docs/timber-and-twig-authoring.md b/docs/timber-and-twig-authoring.md index 1aad0e8..48bace7 100644 --- a/docs/timber-and-twig-authoring.md +++ b/docs/timber-and-twig-authoring.md @@ -162,6 +162,74 @@ The parent theme registers Core-style helpers for class and attribute handling: The helpers return an attribute bag that serializes safely in Twig string contexts. +## Helper characterization + +The parent implements `bem()` and `add_attributes()` in PHP; Storybook uses +Emulsify Core's JavaScript helpers. Their shared API does not guarantee identical +behavior for every input. The maintainer contract records each implementation's +observed behavior separately in +`.github/fixtures/twig-helper-contract.json` in the repository checkout. +It uses the WordPress parent's own `AttributeBag`; Core's +[4.5.0 PHP corpus](https://github.com/emulsify-ds/emulsify-core/blob/v4.5.0/docs/twig-php-parity.md) +instead records a pinned Emulsify Tools counterpart. That corpus's portable +subset is not a guarantee about this WordPress implementation. + +This check is for a maintainer checkout; its `.github` scripts and fixtures are +excluded from released parent ZIPs. Install the parent Composer dependencies +and the starter's declared Core dependency, then run it from the repository root: + +```sh +composer install +npm run whisk:install +npm run test:twig-helpers +``` + +The command uses `whisk/` by default. To characterize the Core version installed +in another generated theme, pass its directory: + +```sh +npm run test:twig-helpers -- "/path/to/generated-theme" +``` + +Each case compares three observations against that runtime's own expectations: +the exact serialized HTML string, the returned attribute map, and +`contextAfter`, the attributes remaining in the caller's context. Whitespace, +escaping, attribute types and order, and context mutation remain visible. The +check does not trim output, rewrite escaping, or normalize known differences +to make the PHP and JavaScript results appear equal. + +The recorded ordinary positional/object BEM cases, aliases, nested lists, class +sanitization, list replacement, scalar number serialization, and escaping agree. +Selected differences remain explicit: + +| Recorded input | WordPress PHP | Core 4.5.0 JavaScript | +| --- | --- | --- | +| A `true` BEM modifier | `card--1` | `card--true` | +| Numeric `0` as an extra class | `_0` | Omitted | +| `true` in an `add_attributes()` class list | `_1` | `true` | +| `true` in a non-class attribute list | `1` | `true` | +| Caller attributes supplied as an array or attribute bag | Copied; remain in caller context | Consumed from invocation context | + +The fixture cases `bem-boolean-and-numeric-values`, +`add-booleans-aria-and-falsy-classes`, and +`add-invalid-names-and-nested-values` record the coercion differences. +`add-repeated-context-consumption` demonstrates that WordPress prints caller +attributes again on a second call, whereas Core has consumed them. WordPress's +own `AttributeBag` preserves the caller context too; changing from an array to +that bag does not provide print-once behavior. + +Both implementations currently treat boolean ARIA values like HTML booleans: +`true` can produce a bare attribute, and `false` can be omitted rather than +replacing an existing value. Supply explicit strings such as +`{ 'aria-expanded': 'false' }` for ARIA state values. This describes the tested +inputs, not equivalence for arbitrary attributes or Twig expressions. + +Review changed expectations when updating a helper or Core dependency. A +characterization fixture records compatibility evidence; it does not decide +which runtime should change or promise universal PHP/JavaScript parity. The +check does not replace Twig autoescaping coverage or rendering the affected +template in WordPress with its actual Timber context and hooks. + ## Switch statements The parent theme registers the Emulsify-compatible `switch`, `case`, `default`, and `endswitch` tags used by Emulsify Core in Storybook and Emulsify Tools in Drupal. Use `or` to match more than one value in a case: diff --git a/package.json b/package.json index b7f056c..9ba4114 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "test:audit-wrappers": "node --test .github/scripts/generated-audit-wrappers.test.cjs", "test:a11y-runner": "node --test .github/scripts/a11y-runner.test.cjs", "test:lint-wrappers": "node --test .github/scripts/lint-wrappers.test.cjs", + "test:twig-helpers": "node .github/scripts/twig-helper-contract.cjs", "smoke:acf-json": "php .github/scripts/acf-local-json-smoke.php", "smoke:asset-manifest": "php .github/scripts/asset-manifest-smoke.php", "smoke:attributes": "php .github/scripts/attribute-helper-smoke.php", From 6e82b68dedfd98e8faadbae63bc6b3f5f3cb10db Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Fri, 23 Oct 2026 08:53:23 -0500 Subject: [PATCH 025/100] fix(twig): separate injected context in argumentless helper calls --- .github/fixtures/twig-helper-contract.json | 8 ++------ UPGRADE.md | 6 ++++++ docs/release-notes-next.md | 4 ++++ docs/timber-and-twig-authoring.md | 5 +++++ includes/Runtime/Twig.php | 16 ++++++++++++++-- 5 files changed, 31 insertions(+), 8 deletions(-) diff --git a/.github/fixtures/twig-helper-contract.json b/.github/fixtures/twig-helper-contract.json index 7a15614..dd5fbb9 100644 --- a/.github/fixtures/twig-helper-contract.json +++ b/.github/fixtures/twig-helper-contract.json @@ -156,11 +156,9 @@ "context": {"id": "existing", "class": ["context"]}, "contextVars": {"fixture_secret": "do-not-print"}, "args": [], - "php": {"attributes": {"fixture_secret": "do-not-print", "attributes": ["existing", "context"]}, "html": "fixture_secret=\"do-not-print\" attributes=\"existing context\"", "contextAfter": {"id": "existing", "class": ["context"]}}, + "php": {"attributes": {"id": "existing", "class": ["context"]}, "html": "id=\"existing\" class=\"context\"", "contextAfter": {"id": "existing", "class": ["context"]}}, "js": {"attributes": {"id": "existing", "class": ["context"]}, "html": "id=\"existing\" class=\"context\"", "contextAfter": {}}, "differences": [ - {"field": "attributes", "reason": "Current parent context shifting requires a second argument, so a no-argument call mistakes the entire Twig context for additional attributes and exposes unrelated variables. This records a bug, not an approved behavior."}, - {"field": "html", "reason": "The no-argument PHP context-shifting bug serializes unrelated context values instead of the intended id/class. Core correctly merges only context attributes."}, {"field": "contextAfter", "reason": "WordPress leaves source attributes untouched, while Core consumes them."} ] }, @@ -170,11 +168,9 @@ "context": {"id": "existing", "class": ["context"]}, "contextVars": {"block": "unrelated-block"}, "args": [], - "php": {"attributes": {"id": "existing", "class": ["context", "unrelated-block"]}, "html": "id=\"existing\" class=\"context unrelated-block\"", "contextAfter": {"id": "existing", "class": ["context"]}}, + "php": {"attributes": {"id": "existing", "class": ["context"]}, "html": "id=\"existing\" class=\"context\"", "contextAfter": {"id": "existing", "class": ["context"]}}, "js": {"attributes": {"id": "existing", "class": ["context"]}, "html": "id=\"existing\" class=\"context\"", "contextAfter": {}}, "differences": [ - {"field": "attributes", "reason": "The current no-argument parent call mistakes the entire Twig context for BEM options, allowing an unrelated context block variable to become a class. This records a bug, not an approved behavior."}, - {"field": "html", "reason": "The context-shifting bug adds an unrelated class to no-argument bem output; Core only uses context attributes."}, {"field": "contextAfter", "reason": "WordPress leaves source attributes untouched, while Core consumes them."} ] }, diff --git a/UPGRADE.md b/UPGRADE.md index 6e1e7f0..172c6f9 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -6,6 +6,12 @@ then decide which starter changes to adopt in each generated project. ## Upgrading from 2.0.0 to 2.1.0 +Argumentless Twig `bem()` and `add_attributes()` now read only inherited +`attributes`, rather than interpreting unrelated Timber context variables as +attributes or BEM options. Pass intended values explicitly if a template relied +on that accidental output. Normal explicit-argument calls and the direct PHP +helper API are unchanged; the parent update supplies this fix automatically. + New generated themes include `AGENTS.md`, a WordPress integration guide, and the optional `.agents/skills/emulsify-wordpress-integration/` workflow skill. Existing child themes can copy these token-free files from a fresh comparison diff --git a/docs/release-notes-next.md b/docs/release-notes-next.md index 6a8d21c..1ec4e17 100644 --- a/docs/release-notes-next.md +++ b/docs/release-notes-next.md @@ -88,6 +88,10 @@ Title: `2.1.0` ### Fixed +- Argumentless Twig `bem()` and `add_attributes()` calls no longer mistake the + whole Timber context for helper arguments. Unrelated variables cannot leak + into attributes or supply unintended BEM classes. Explicit-argument behavior + and the direct PHP API remain unchanged. - The document shell renders Timber's trusted WordPress language attributes as markup so `lang` is valid. The failing pale-blue normal link color is removed from parent `theme.json`; normal colors fall through to core, browser, or diff --git a/docs/timber-and-twig-authoring.md b/docs/timber-and-twig-authoring.md index 48bace7..aa804d1 100644 --- a/docs/timber-and-twig-authoring.md +++ b/docs/timber-and-twig-authoring.md @@ -162,6 +162,11 @@ The parent theme registers Core-style helpers for class and attribute handling: The helpers return an attribute bag that serializes safely in Twig string contexts. +Argumentless Twig calls such as `{{ add_attributes() }}` and `{{ bem() }}` use +only the context's `attributes`. Other context variables are not attributes or +BEM options. The registered callbacks keep that injected context separate from +explicit arguments; direct PHP helper calls retain their existing API. + ## Helper characterization The parent implements `bem()` and `add_attributes()` in PHP; Storybook uses diff --git a/includes/Runtime/Twig.php b/includes/Runtime/Twig.php index 479690a..b994b6b 100644 --- a/includes/Runtime/Twig.php +++ b/includes/Runtime/Twig.php @@ -129,8 +129,15 @@ public function loader_paths( $loader ) { * @return array Updated Timber functions. */ public function functions( array $functions ): array { + // Timber injects context even when Twig supplies no arguments. Pad that + // case so it cannot be mistaken for the direct PHP single-argument form. $functions['bem'] = array( - 'callable' => array( $this, 'bem' ), + 'callable' => function ( array $context, ...$arguments ): AttributeBag { + if ( empty( $arguments ) ) { + $arguments[] = ''; + } + return $this->bem( $context, ...$arguments ); + }, 'options' => array( 'needs_context' => true, 'is_safe' => array( 'html' ), @@ -138,7 +145,12 @@ public function functions( array $functions ): array { ); $functions['add_attributes'] = array( - 'callable' => array( $this, 'add_attributes' ), + 'callable' => function ( array $context, ...$arguments ): AttributeBag { + if ( empty( $arguments ) ) { + $arguments[] = array(); + } + return $this->add_attributes( $context, ...$arguments ); + }, 'options' => array( 'needs_context' => true, 'is_safe' => array( 'html' ), From d7d5867ffdbe197648620dcf2378f0a0da931244 Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Fri, 23 Oct 2026 10:21:23 -0500 Subject: [PATCH 026/100] chore(deps-dev): update vulnerable release tooling dependencies --- package-lock.json | 46 +++++++++++++++++----------------------------- package.json | 2 +- 2 files changed, 18 insertions(+), 30 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6b1c250..408b755 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,7 @@ "@semantic-release/github": "^12.0.9", "@semantic-release/release-notes-generator": "^14.1.1", "husky": "^9.1.7", - "js-yaml": "4.3.0", + "js-yaml": "4.3.2", "lint-staged": "^17.3.0", "semantic-release": "^25.0.8" }, @@ -56,9 +56,9 @@ } }, "node_modules/@actions/http-client/node_modules/undici": { - "version": "6.27.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.27.0.tgz", - "integrity": "sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg==", + "version": "6.29.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.29.1.tgz", + "integrity": "sha512-EvRlUt/QqAEvXdQeRMYUfwOQsfzpDztykrG2owGEE/rBOBoYv9uF39ZzsNDh4lTFHhTr2XOZjKkYgueD12ZmGA==", "dev": true, "license": "MIT", "engines": { @@ -329,18 +329,6 @@ "url": "https://ko-fi.com/dangreen" } }, - "node_modules/@commitlint/read/node_modules/conventional-commits-filter": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/conventional-commits-filter/-/conventional-commits-filter-6.0.1.tgz", - "integrity": "sha512-cs+LadpH7Kpw0M3k8wurk+sOVVDAENA0iK4OBOrkL94j5lEVYRJ4j3zd2bhY9qgzyrPqthdcYT3axzRN7AliMg==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=22" - } - }, "node_modules/@commitlint/resolve-extends": { "version": "21.2.0", "resolved": "https://registry.npmjs.org/@commitlint/resolve-extends/-/resolve-extends-21.2.0.tgz", @@ -4052,9 +4040,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", - "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.9.tgz", + "integrity": "sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ==", "dev": true, "funding": [ { @@ -4240,13 +4228,13 @@ "license": "ISC" }, "node_modules/handlebars": { - "version": "4.7.9", - "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz", - "integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==", + "version": "4.7.10", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.10.tgz", + "integrity": "sha512-P5VJMVM7qgBn6vjXMw8WG9uVI+ncf2pi72j4de4yz5ZULLj2RGqLYaKOYGsgyrViQ0tePOVlN1tDCCXXtFqXKg==", "dev": true, "license": "MIT", "dependencies": { - "minimist": "^1.2.5", + "minimist": "^1.2.8", "neo-async": "^2.6.2", "source-map": "^0.6.1", "wordwrap": "^1.0.0" @@ -4547,9 +4535,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", - "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", "dev": true, "funding": [ { @@ -6411,9 +6399,9 @@ } }, "node_modules/undici": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", - "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "version": "7.30.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.30.1.tgz", + "integrity": "sha512-03KrMbjIFDWQR5RNPrSw3bd1r12JpyFQl9/yxjUbJtqkjmllqC5lFjZh6ujeuJCKr8EOQvRW872Oy/az+nLYxg==", "dev": true, "license": "MIT", "engines": { diff --git a/package.json b/package.json index 9ba4114..0c87d59 100644 --- a/package.json +++ b/package.json @@ -73,7 +73,7 @@ "@semantic-release/github": "^12.0.9", "@semantic-release/release-notes-generator": "^14.1.1", "husky": "^9.1.7", - "js-yaml": "4.3.0", + "js-yaml": "4.3.2", "lint-staged": "^17.3.0", "semantic-release": "^25.0.8" } From 62a05e9981fc219db670d1ce439f96533716249d Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Fri, 23 Oct 2026 10:22:00 -0500 Subject: [PATCH 027/100] ci: pin actions and enforce supported readiness matrices --- .github/dependabot.yml | 6 + .github/scripts/pr-validation.cjs | 1 + .github/scripts/readiness-matrix-contract.cjs | 60 ++++++++ .../readiness-matrix-contract.test.cjs | 128 ++++++++++++++++++ .github/scripts/release-check.cjs | 66 ++++++++- .github/scripts/wordpress-fixture-smoke.cjs | 2 + .github/workflows/addtoprojects.yml | 7 +- .github/workflows/contributors.yml | 13 +- .github/workflows/semantic-release.yml | 29 ++-- .github/workflows/theme-readiness.yml | 57 ++++---- style.css | 2 +- 11 files changed, 322 insertions(+), 49 deletions(-) create mode 100644 .github/dependabot.yml create mode 100644 .github/scripts/readiness-matrix-contract.cjs create mode 100644 .github/scripts/readiness-matrix-contract.test.cjs diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..ca79ca5 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,6 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly diff --git a/.github/scripts/pr-validation.cjs b/.github/scripts/pr-validation.cjs index 915b676..20b7394 100644 --- a/.github/scripts/pr-validation.cjs +++ b/.github/scripts/pr-validation.cjs @@ -24,6 +24,7 @@ function run(label, command, args) { run('Validate Composer metadata', 'composer', ['validate', '--no-check-publish', '--strict']); run('Validate documented npm commands', 'npm', ['run', 'docs:check-commands']); +run('Protect blocking readiness coverage', 'node', ['--test', '.github/scripts/readiness-matrix-contract.test.cjs']); run('Install Composer dependencies for Twig smoke coverage', 'composer', [ 'install', '--no-interaction', diff --git a/.github/scripts/readiness-matrix-contract.cjs b/.github/scripts/readiness-matrix-contract.cjs new file mode 100644 index 0000000..61a4278 --- /dev/null +++ b/.github/scripts/readiness-matrix-contract.cjs @@ -0,0 +1,60 @@ +const assert = require('node:assert/strict'); +const yaml = require('js-yaml'); + +function validateReadinessMatrix(source, { minimumWordPress = '6.8', requiredPhpVersions = ['8.3', '8.4', '8.5'] } = {}) { + const workflow = yaml.load(source); + + function entriesFor(jobName, versionKey) { + const job = workflow.jobs?.[jobName]; + assert.ok(job, `theme-readiness.yml must keep the ${jobName} job.`); + assert.equal(job['continue-on-error'], '${{ matrix.experimental }}', `${jobName} must make only matrix.experimental legs advisory.`); + assert.ok(!job.strategy?.matrix?.exclude, `${jobName} must not exclude required readiness legs.`); + const entries = job.strategy?.matrix?.include; + assert.ok(Array.isArray(entries) && entries.length, `${jobName} must declare its readiness matrix with include.`); + const versions = new Set(); + for (const entry of entries) { + assert.equal(typeof entry[versionKey], 'string', `${jobName} must declare each ${versionKey} as a string.`); + assert.equal(typeof entry.experimental, 'boolean', `${jobName} must explicitly mark each leg's experimental status.`); + assert.ok(!versions.has(entry[versionKey]), `${jobName} must not duplicate ${entry[versionKey]} readiness legs.`); + versions.add(entry[versionKey]); + } + return { job, entries }; + } + + function requireLeg(entries, versionKey, version, experimental, jobName) { + assert.ok(entries.some((entry) => entry[versionKey] === version && entry.experimental === experimental), + `${jobName} must keep ${version} ${experimental ? 'advisory' : 'blocking'}.`); + } + + function requireCommand(job, command, jobName) { + const steps = (job.steps || []).filter((step) => step.run?.trim() === command); + assert.ok(steps.length, `${jobName} must run ${command}.`); + for (const step of steps) { + assert.ok(step['continue-on-error'] === undefined || step['continue-on-error'] === false, + `${jobName}: ${command} must not allow failures.`); + assert.equal(step.if, undefined, `${jobName}: ${command} must run on every matrix leg.`); + } + return steps; + } + + const practical = entriesFor('practical', 'php-version'); + for (const version of requiredPhpVersions) requireLeg(practical.entries, 'php-version', version, false, 'practical'); + requireCommand(practical.job, 'npm run pr:check', 'practical'); + requireCommand(practical.job, 'npm run release:check', 'practical'); + + const fixture = entriesFor('wordpress-fixture', 'wordpress-version'); + requireLeg(fixture.entries, 'wordpress-version', minimumWordPress, false, 'wordpress-fixture'); + requireLeg(fixture.entries, 'wordpress-version', 'latest', false, 'wordpress-fixture'); + requireLeg(fixture.entries, 'wordpress-version', 'nightly', true, 'wordpress-fixture'); + for (const step of requireCommand(fixture.job, 'npm run release:check', 'wordpress-fixture')) { + assert.equal(step.env?.WP_SMOKE_WORDPRESS_VERSION, '${{ matrix.wordpress-version }}', + 'WordPress fixture smoke must resolve its version from matrix.wordpress-version, not a hardcoded version.'); + assert.equal(step.env?.WP_SMOKE_REQUIRED, '1', 'WordPress fixture matrix must fail when smoke prerequisites are missing.'); + assert.equal(step.env?.WP_SMOKE_A11Y, '1', 'WordPress fixture matrix must audit rendered accessibility.'); + const env = { ...workflow.env, ...fixture.job.env, ...step.env }; + assert.notEqual(String(env.WP_SMOKE_SKIP), '1', 'WordPress fixture matrix must not skip its smoke test.'); + } + return { practical: practical.entries, wordpress: fixture.entries }; +} + +module.exports = { validateReadinessMatrix }; diff --git a/.github/scripts/readiness-matrix-contract.test.cjs b/.github/scripts/readiness-matrix-contract.test.cjs new file mode 100644 index 0000000..87e403a --- /dev/null +++ b/.github/scripts/readiness-matrix-contract.test.cjs @@ -0,0 +1,128 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const test = require('node:test'); +const yaml = require('js-yaml'); +const { validateReadinessMatrix } = require('./readiness-matrix-contract.cjs'); + +function fixture() { + return { + jobs: { + practical: { + strategy: { matrix: { include: [ + { 'php-version': '8.3', experimental: false }, + { 'php-version': '8.4', experimental: false }, + { 'php-version': '8.5', experimental: false }, + ] } }, + 'continue-on-error': '${{ matrix.experimental }}', + steps: [{ run: 'npm run pr:check' }, { run: 'npm run release:check' }], + }, + 'wordpress-fixture': { + strategy: { matrix: { include: [ + { 'wordpress-version': '6.8', experimental: false }, + { 'wordpress-version': 'latest', experimental: false }, + { 'wordpress-version': 'nightly', experimental: true }, + ] } }, + 'continue-on-error': '${{ matrix.experimental }}', + steps: [{ + run: 'npm run release:check', + env: { WP_SMOKE_WORDPRESS_VERSION: '${{ matrix.wordpress-version }}', WP_SMOKE_REQUIRED: '1', WP_SMOKE_A11Y: '1' }, + }], + }, + }, + }; +} + +function validate(workflow = fixture(), options) { + return validateReadinessMatrix(yaml.dump(workflow), options); +} + +test('accepts minimum and current stable blocking, with an advisory preview', () => { + assert.equal(validate().wordpress.length, 3); +}); + +test('validates the committed workflow and declared theme minimum', () => { + const repoRoot = path.resolve(__dirname, '../..'); + const workflow = fs.readFileSync(path.join(repoRoot, '.github/workflows/theme-readiness.yml'), 'utf8'); + const minimumWordPress = fs.readFileSync(path.join(repoRoot, 'style.css'), 'utf8').match(/Requires at least:\s*(\S+)/)[1]; + validateReadinessMatrix(workflow, { minimumWordPress }); +}); + +test('rejects removed or advisory minimum, latest, and baseline PHP legs', () => { + for (const [jobName, versionKey, version] of [ + ['wordpress-fixture', 'wordpress-version', '6.8'], + ['wordpress-fixture', 'wordpress-version', 'latest'], + ['practical', 'php-version', '8.3'], + ['practical', 'php-version', '8.4'], + ['practical', 'php-version', '8.5'], + ]) { + for (const remove of [false, true]) { + const workflow = fixture(); + const entries = workflow.jobs[jobName].strategy.matrix.include; + if (remove) entries.splice(entries.findIndex((entry) => entry[versionKey] === version), 1); + else entries.find((entry) => entry[versionKey] === version).experimental = true; + assert.throws(() => validate(workflow), /must keep .* blocking/); + } + } +}); + +test('requires the nightly preview to remain advisory', () => { + const workflow = fixture(); + workflow.jobs['wordpress-fixture'].strategy.matrix.include[2].experimental = false; + assert.throws(() => validate(workflow), /must keep nightly advisory/); +}); + +test('rejects job-wide failure bypasses and matrix exclusions', () => { + for (const jobName of ['practical', 'wordpress-fixture']) { + const bypass = fixture(); + bypass.jobs[jobName]['continue-on-error'] = true; + assert.throws(() => validate(bypass), /only matrix.experimental legs advisory/); + const excluded = fixture(); + excluded.jobs[jobName].strategy.matrix.exclude = [{ experimental: false }]; + assert.throws(() => validate(excluded), /must not exclude/); + } +}); + +test('rejects string experimental flags, numeric versions, and duplicate legs', () => { + const flag = fixture(); + flag.jobs.practical.strategy.matrix.include[0].experimental = 'false'; + assert.throws(() => validate(flag), /explicitly mark/); + const numeric = fixture(); + numeric.jobs.practical.strategy.matrix.include[0]['php-version'] = 8.3; + assert.throws(() => validate(numeric), /as a string/); + const duplicate = fixture(); + duplicate.jobs.practical.strategy.matrix.include.push({ 'php-version': '8.3', experimental: true }); + assert.throws(() => validate(duplicate), /must not duplicate/); +}); + +test('rejects missing, conditional, skipped, or failure-tolerant readiness commands', () => { + for (const jobName of ['practical', 'wordpress-fixture']) { + for (const mutation of ['remove', 'if', 'continue-on-error', 'skip-smoke']) { + const workflow = fixture(); + const step = workflow.jobs[jobName].steps.find((entry) => entry.run === 'npm run release:check'); + if (mutation === 'remove') workflow.jobs[jobName].steps = []; + else if (mutation === 'skip-smoke') step.run += ' -- --skip-smoke'; + else step[mutation] = true; + assert.throws(() => validate(workflow), /must (run|not allow failures)/); + } + } +}); + +test('rejects hardcoded fixture versions or disabled fixture checks', () => { + for (const [key, value] of [['WP_SMOKE_WORDPRESS_VERSION', '6.8'], ['WP_SMOKE_REQUIRED', '0'], ['WP_SMOKE_A11Y', '0'], ['WP_SMOKE_SKIP', '1']]) { + const workflow = fixture(); + workflow.jobs['wordpress-fixture'].steps[0].env[key] = value; + assert.throws(() => validate(workflow), /WordPress fixture/); + } + for (const scope of ['workflow', 'job']) { + const workflow = fixture(); + (scope === 'workflow' ? workflow : workflow.jobs['wordpress-fixture']).env = { WP_SMOKE_SKIP: '1' }; + assert.throws(() => validate(workflow), /must not skip/); + } +}); + +test('keeps the validated PHP 8.5 lane blocking', () => { + const workflow = fixture(); + workflow.jobs.practical.strategy.matrix.include[2].experimental = true; + assert.throws(() => validate(workflow), /must keep 8.5 blocking/); +}); diff --git a/.github/scripts/release-check.cjs b/.github/scripts/release-check.cjs index 1e4b292..7bf8b49 100644 --- a/.github/scripts/release-check.cjs +++ b/.github/scripts/release-check.cjs @@ -7,7 +7,9 @@ const fs = require('fs'); const path = require('path'); const childProcess = require('child_process'); +const yaml = require('js-yaml'); const { validateReleaseVersionContract } = require('./release-version-contract.cjs'); +const { validateReadinessMatrix } = require('./readiness-matrix-contract.cjs'); const repoRoot = path.resolve(__dirname, '../..'); const skipSmoke = process.argv.slice(2).includes('--skip-smoke'); @@ -316,6 +318,8 @@ function runStaticChecks() { '.github/scripts/release-check.cjs', '.github/scripts/release-version-contract.cjs', '.github/scripts/release-version-contract.test.cjs', + '.github/scripts/readiness-matrix-contract.cjs', + '.github/scripts/readiness-matrix-contract.test.cjs', '.github/scripts/semantic-release.test.cjs', '.github/scripts/fallback-navigation-smoke.php', '.github/scripts/rendered-a11y.cjs', @@ -548,9 +552,9 @@ function runStaticChecks() { runStaticCheck('WordPress tested compatibility', () => { const tested = rootThemeHeader['Tested up to']; const minimum = rootThemeHeader['Requires at least']; - ensure(tested === '6.8', 'style.css Tested up to must match the blocking WordPress 6.8 fixture for this release.'); + ensure(/^\d+\.\d+(?:\.\d+)?$/.test(tested), 'style.css Tested up to must name a stable WordPress version verified by the blocking fixture.'); ensure(wordpressVersionAtLeast(tested, minimum), 'style.css Tested up to must not be lower than Requires at least.'); - return `Tested up to ${tested} matches the blocking fixture and covers theme minimum ${minimum}.`; + return `Tested up to ${tested} covers theme minimum ${minimum}; the current stable WordPress fixture is blocking.`; }); runStaticCheck('PHP analysis scope', () => { @@ -1308,7 +1312,8 @@ function runStaticChecks() { const readinessJob = semanticReleaseWorkflow.slice(readinessJobStart, releaseJobStart); const releaseJob = semanticReleaseWorkflow.slice(releaseJobStart); const buildStep = releaseJob.indexOf('npm run build:dist'); - const publishStep = releaseJob.indexOf('npm run publish'); + const publishStep = releaseJob.indexOf('run: npm run publish\n'); + const dryRunStep = releaseJob.indexOf('run: npm run publish-test -- --no-ci'); const releaseAsset = Array.isArray(githubOptions.assets) ? githubOptions.assets.find((asset) => asset.path === 'dist-artifact/emulsify.zip') : null; @@ -1340,6 +1345,8 @@ function runStaticChecks() { ensure(releaseJob.includes('tools: composer:v2'), 'semantic-release.yml release job should install Composer.'); ensure(buildStep >= 0, 'semantic-release.yml release job should build the installable theme archive.'); ensure(publishStep >= 0 && buildStep < publishStep, 'semantic-release.yml should build the installable theme archive before publishing.'); + ensure(dryRunStep >= 0 && dryRunStep < publishStep, 'semantic-release.yml should run the dry run in the publishing job before publishing.'); + ensure(!readinessJob.includes('npm run publish-test') && !readinessJob.includes('GITHUB_TOKEN:'), 'Read-only release readiness must not run the authenticated semantic-release dry run.'); ensure(semanticReleaseRunner.includes('Refusing to publish local-only stable tag(s)'), 'The guarded runner should refuse to push unpublished local stable tags.'); return 'Semantic release requires a prepared package version, non-prefixed tags, main-only publishing, and the tested installable ZIP asset.'; }); @@ -1398,7 +1405,9 @@ function runStaticChecks() { ensure(themeReadinessWorkflow.includes('npm run release:check'), 'theme-readiness.yml should run release readiness checks.'); ensure(practicalJob.includes('fetch-depth: 0') && wordpressFixtureJob.includes('fetch-depth: 0'), 'Readiness jobs running the release version contract should fetch full history and tags.'); ensure(practicalJob.includes("- name: Build installable theme archive\n if: matrix.php-version == '8.3'\n run: npm run build:dist"), 'The PHP 8.3 practical lane should build the client-testable release archive.'); - ensure(practicalJob.includes("- name: Upload installable theme archive\n if: matrix.php-version == '8.3'\n uses: actions/upload-artifact@v7"), 'The PHP 8.3 practical lane should use direct-file artifact upload.'); + const readinessJobs = yaml.load(themeReadinessWorkflow).jobs; + const archiveUpload = readinessJobs.practical.steps.find((step) => step.name === 'Upload installable theme archive'); + ensure(archiveUpload?.if === "matrix.php-version == '8.3'" && /^actions\/upload-artifact@[a-f0-9]{40}$/.test(archiveUpload.uses), 'The PHP 8.3 practical lane should use a pinned artifact upload.'); ensure(practicalJob.includes('path: dist-artifact/emulsify.zip') && practicalJob.includes('archive: false') && practicalJob.includes('overwrite: true'), 'Theme readiness should upload the installable ZIP directly and support workflow reruns.'); ensure(practicalJob.includes('if-no-files-found: error') && practicalJob.includes('retention-days: 14'), 'The client-testable artifact should be required and retained for 14 days.'); ensure(practicalStart >= 0 && phpLintStart > practicalStart && wordpressFixtureStart > phpLintStart && extendedWhiskStart > wordpressFixtureStart, 'theme-readiness.yml should define practical, PHP, WordPress fixture, and Whisk accessibility jobs.'); @@ -1413,6 +1422,7 @@ function runStaticChecks() { ensure(wordpressFixtureJob.includes("WP_SMOKE_REQUIRED: '1'"), 'WordPress fixture job should require the fixture instead of allowing a skip.'); ensure(wordpressFixtureJob.includes('- name: Build installable theme archive') && wordpressFixtureJob.includes('run: npm run build:dist'), 'WordPress fixture job should build the installable archive.'); ensure(wordpressFixtureJob.includes('WP_SMOKE_THEME_ARCHIVE: dist-artifact/emulsify.zip'), 'WordPress fixture job should test the built archive instead of the source tree.'); + ensure(wordpressFixtureSmoke.includes("['core', 'version']") && wordpressFixtureSmoke.includes('WordPress version: requested') && wordpressFixtureSmoke.includes('resolved ${resolvedWordpressVersion}'), 'WordPress fixture smoke should log the actual downloaded core version, including latest and nightly resolution.'); ensure(themeReadinessWorkflow.includes('extended_checks'), 'theme-readiness.yml should expose optional extended checks.'); ensure(extendedWhiskJob.includes(releaseTargetedPrGate), 'Whisk accessibility job should run on release-targeted pull requests.'); ensure(extendedWhiskJob.includes("github.event_name == 'schedule'"), 'Whisk accessibility job should run on the schedule.'); @@ -1457,6 +1467,53 @@ function runStaticChecks() { return 'Theme readiness runs fast checks on every configured pull request and extended checks for release targets, schedules, and manual dispatches.'; }); + runStaticCheck('Readiness matrix contract', () => { + const entries = validateReadinessMatrix(themeReadinessWorkflow, { + minimumWordPress: rootThemeHeader['Requires at least'], + }); + return `Blocking WordPress minimum and latest, advisory nightly, and ${entries.practical.filter((entry) => !entry.experimental).length} blocking PHP legs are required.`; + }); + + runStaticCheck('Pinned workflow actions and validation credentials', () => { + const files = listFilesRecursive('.github/workflows', (file) => /\.ya?ml$/.test(file)); + for (const file of files) { + const source = readFile(file); + for (const line of source.split(/\r?\n/)) { + if (!/^\s*(?:-\s*)?uses:\s*/.test(line)) continue; + ensure(/^\s*(?:-\s*)?uses:\s*[\w.-]+\/[\w./-]+@[a-f0-9]{40}\s+#\s*v\d+\.\d+\.\d+(?:[\w.+-]*)?\s*$/.test(line), + `${file} must pin every action to a full commit SHA with its release version comment: ${line.trim()}`); + if (line.includes('actions/upload-artifact@')) ensure(/#\s*v7\./.test(line), `${file} must use the Node 24 upload-artifact v7 release.`); + } + const workflow = yaml.load(source); + ensure(workflow.permissions?.contents === 'read' && Object.keys(workflow.permissions).length === 1, + `${file} must default to exactly contents: read.`); + ensure(!Object.hasOwn(workflow.on || {}, 'pull_request_target'), `${file} must not run untrusted pull requests with pull_request_target credentials.`); + for (const [jobName, job] of Object.entries(workflow.jobs || {})) { + const publishingJob = file === '.github/workflows/semantic-release.yml' && jobName === 'semantic-release'; + const contributorsJob = file === '.github/workflows/contributors.yml' && jobName === 'add-contributors'; + if (publishingJob) { + ensure(job.permissions?.contents === 'write' && job.permissions?.issues === 'write' + && job.permissions?.['pull-requests'] === 'write' && Object.keys(job.permissions).length === 3, + 'The semantic-release publishing job must request exactly contents, issues, and pull-requests write permissions.'); + } + else if (contributorsJob) { + ensure(job.permissions?.contents === 'write' && Object.keys(job.permissions).length === 1, + 'Contributor automation must request only contents: write.'); + } + else { + ensure(job.permissions === undefined || (typeof job.permissions === 'object' + && Object.values(job.permissions).every((value) => value === 'read' || value === 'none')), + `${file} ${jobName} validation permissions must remain read-only.`); + } + for (const step of job.steps || []) { + if (!step.uses?.startsWith('actions/checkout@') || publishingJob) continue; + ensure(step.with?.['persist-credentials'] === false, `${file} ${jobName} validation checkout must disable persisted credentials.`); + } + } + } + return 'All workflow actions use full SHA pins with version comments, and validation checkouts do not retain credentials.'; + }); + runStaticCheck('Release documentation', () => { const expectedDocLinks = [ 'docs/README.md', @@ -1737,6 +1794,7 @@ function runStaticChecks() { }); runCommandCheck('Release version contract tests', process.execPath, ['--test', '.github/scripts/release-version-contract.test.cjs']); + runCommandCheck('Readiness matrix contract tests', process.execPath, ['--test', '.github/scripts/readiness-matrix-contract.test.cjs']); runCommandCheck('Semantic release guard tests', process.execPath, ['--test', '.github/scripts/semantic-release.test.cjs']); if (skipSmoke) { diff --git a/.github/scripts/wordpress-fixture-smoke.cjs b/.github/scripts/wordpress-fixture-smoke.cjs index 37bff8f..f41483d 100755 --- a/.github/scripts/wordpress-fixture-smoke.cjs +++ b/.github/scripts/wordpress-fixture-smoke.cjs @@ -978,6 +978,8 @@ async function main() { `--version=${wordpressVersion}`, '--force', ]); + const resolvedWordpressVersion = wp(wpPath, ['core', 'version']); + log(`WordPress version: requested ${wordpressVersion}; resolved ${resolvedWordpressVersion}`); run('Create wp-config.php', 'wp', [ '--allow-root', diff --git a/.github/workflows/addtoprojects.yml b/.github/workflows/addtoprojects.yml index 9fb9df2..27bddb1 100644 --- a/.github/workflows/addtoprojects.yml +++ b/.github/workflows/addtoprojects.yml @@ -9,14 +9,17 @@ on: - opened workflow_dispatch: +permissions: + contents: read + jobs: add-to-project: name: Add issue to project runs-on: ubuntu-latest steps: - - uses: actions/add-to-project@v0.3.0 + - uses: actions/add-to-project@5afcf98fcd03f1c2f92c3c83f58ae24323cc57fd # v2.0.0 with: # You can target a repository in a different organization # to the issue project-url: https://github.com/orgs/emulsify-ds/projects/6 - token: ${{ secrets.ADD_TO_PROJECT_PAT }} + github-token: ${{ secrets.ADD_TO_PROJECT_PAT }} diff --git a/.github/workflows/contributors.yml b/.github/workflows/contributors.yml index 07b7454..57024eb 100644 --- a/.github/workflows/contributors.yml +++ b/.github/workflows/contributors.yml @@ -7,16 +7,23 @@ on: - main workflow_dispatch: +permissions: + contents: read + jobs: add-contributors: runs-on: ubuntu-latest + permissions: + contents: write steps: - - uses: actions/checkout@v2 - - uses: BobAnkh/add-contributors@master + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: BobAnkh/add-contributors@de6c43a0c154b093a210d8bdf7ab283aba8452dd # v0.2.2 with: CONTRIBUTOR: '### Contributors' COLUMN_PER_ROW: '6' - ACCESS_TOKEN: ${{secrets.ADD_TO_PROJECT_PAT}} + ACCESS_TOKEN: ${{ secrets.GITHUB_TOKEN }} IMG_WIDTH: '100' FONT_SIZE: '14' PATH: '/README.md' diff --git a/.github/workflows/semantic-release.yml b/.github/workflows/semantic-release.yml index c9d2446..2d4ed0b 100644 --- a/.github/workflows/semantic-release.yml +++ b/.github/workflows/semantic-release.yml @@ -35,23 +35,22 @@ jobs: --health-timeout=5s --health-retries=10 permissions: - contents: write - issues: write - pull-requests: write + contents: read steps: - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 + persist-credentials: false - name: Setup Node - uses: actions/setup-node@v6 + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 with: node-version-file: .nvmrc cache: npm - name: Setup PHP - uses: shivammathur/setup-php@v2 + uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2.37.2 with: php-version: '8.3' extensions: mysqli @@ -90,11 +89,6 @@ jobs: WP_SMOKE_DB_PASSWORD: root run: npm run release:check - - name: Run release dry run - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: npm run publish-test -- --no-ci - - name: Run full npm audit continue-on-error: true run: npm audit @@ -109,18 +103,18 @@ jobs: pull-requests: write steps: - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 - name: Setup Node - uses: actions/setup-node@v6 + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 with: node-version-file: .nvmrc cache: npm - name: Setup PHP - uses: shivammathur/setup-php@v2 + uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2.37.2 with: php-version: '8.3' tools: composer:v2 @@ -132,6 +126,13 @@ jobs: - name: Build installable theme archive run: npm run build:dist + # semantic-release verifies push permission even in dry-run mode, so + # keep this check in the publishing job with its scoped write token. + - name: Run release dry run + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: npm run publish-test -- --no-ci + - name: Semantic release id: semantic env: diff --git a/.github/workflows/theme-readiness.yml b/.github/workflows/theme-readiness.yml index e5e3895..00c1db6 100644 --- a/.github/workflows/theme-readiness.yml +++ b/.github/workflows/theme-readiness.yml @@ -45,30 +45,31 @@ jobs: strategy: fail-fast: false matrix: - # 8.3 is the supported baseline. 8.4 proves the runtime stays clean on - # the current release, and 8.5 is an advisory forward-compatibility lane. + # The supported baseline and both newer PHP releases are blocking; + # PHP 8.5 passed the Composer, PHP analysis, and practical checks. include: - php-version: '8.3' experimental: false - php-version: '8.4' experimental: false - php-version: '8.5' - experimental: true + experimental: false continue-on-error: ${{ matrix.experimental }} steps: - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + persist-credentials: false fetch-depth: 0 - name: Setup Node - uses: actions/setup-node@v6 + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 with: node-version-file: .nvmrc cache: npm - name: Setup PHP - uses: shivammathur/setup-php@v2 + uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2.37.2 with: php-version: ${{ matrix.php-version }} tools: composer:v2 @@ -81,7 +82,7 @@ jobs: run: composer validate --no-check-publish --strict - name: Install Composer dependencies for runtime smoke coverage - run: composer install --no-interaction --no-progress --prefer-dist ${{ matrix.experimental && '--ignore-platform-req=php+' || '' }} + run: composer install --no-interaction --no-progress --prefer-dist - name: Run runtime npm audit run: npm audit --omit=dev @@ -110,7 +111,7 @@ jobs: - name: Upload installable theme archive if: matrix.php-version == '8.3' - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: path: dist-artifact/emulsify.zip archive: false @@ -120,7 +121,7 @@ jobs: - name: Upload failure diagnostics if: failure() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: practical-readiness-php-${{ matrix.php-version }} path: | @@ -135,16 +136,18 @@ jobs: timeout-minutes: 10 steps: - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Setup Node - uses: actions/setup-node@v6 + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 with: node-version-file: .nvmrc cache: npm - name: Setup PHP - uses: shivammathur/setup-php@v2 + uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2.37.2 with: php-version: '8.3' tools: composer:v2 @@ -168,13 +171,14 @@ jobs: strategy: fail-fast: false matrix: - # 6.8 is the declared baseline in style.css. `latest` is an advisory - # forward-compatibility lane so a WordPress release cannot break the - # theme silently between scheduled runs. + # The declared minimum and dynamically resolved current stable release + # are blocking. Nightly tracks the next release without blocking merges. include: - wordpress-version: '6.8' experimental: false - wordpress-version: latest + experimental: false + - wordpress-version: nightly experimental: true continue-on-error: ${{ matrix.experimental }} services: @@ -192,12 +196,13 @@ jobs: --health-retries=10 steps: - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + persist-credentials: false fetch-depth: 0 - name: Setup Node - uses: actions/setup-node@v6 + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 with: node-version-file: .nvmrc cache: npm @@ -213,7 +218,7 @@ jobs: printf 'PUPPETEER_EXECUTABLE_PATH=%s\n' "$chrome_path" >> "$GITHUB_ENV" - name: Setup PHP - uses: shivammathur/setup-php@v2 + uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2.37.2 with: php-version: '8.3' extensions: mysqli @@ -245,7 +250,7 @@ jobs: - name: Upload rendered WordPress accessibility reports if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: wordpress-rendered-a11y-${{ matrix.wordpress-version }} path: .artifacts/wordpress-a11y/ @@ -255,11 +260,11 @@ jobs: - name: Upload fixture diagnostics if: failure() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: wordpress-fixture-${{ matrix.wordpress-version }} path: | - /tmp/emulsify-wp-smoke*/**/*.log + /tmp/emulsify-wordpress-smoke*/**/*.log npm-debug.log* if-no-files-found: ignore retention-days: 7 @@ -275,10 +280,12 @@ jobs: timeout-minutes: 20 steps: - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Setup Node - uses: actions/setup-node@v6 + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 with: node-version-file: .nvmrc cache: npm @@ -312,7 +319,7 @@ jobs: - name: Upload Whisk coverage if: ${{ !cancelled() }} - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: extended-whisk-coverage path: whisk/.coverage/ @@ -322,7 +329,7 @@ jobs: - name: Upload Storybook diagnostics if: failure() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: extended-whisk-storybook path: | diff --git a/style.css b/style.css index bda4113..d1509c6 100644 --- a/style.css +++ b/style.css @@ -4,7 +4,7 @@ * Description: Timber-first WordPress parent theme for generated child themes with Emulsify Core 4, Vite, Storybook, and Twig. * Text Domain: emulsify * Requires at least: 6.8 - * Tested up to: 6.8 + * Tested up to: 7.1 * Requires PHP: 8.3 * Version: 2.0.0 * License: GPL-2.0-only From 91a1ccacd9a8620fc44b0931ff1089d4bae43fe2 Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Fri, 23 Oct 2026 10:24:04 -0500 Subject: [PATCH 028/100] docs: record readiness coverage and dependency audit --- README.md | 13 ++++- docs/README.md | 1 + docs/dependency-audit-2026-10-23.md | 76 +++++++++++++++++++++++++++++ docs/release-notes-next.md | 15 +++++- docs/release-process.md | 27 +++++++++- 5 files changed, 128 insertions(+), 4 deletions(-) create mode 100644 docs/dependency-audit-2026-10-23.md diff --git a/README.md b/README.md index ab920a2..00624c0 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -![Emulsify Design System](https://github.com/emulsify-ds/.github/blob/6bd435be881bd820bddfa05d88905efe29176a0a/assets/images/header.png) +![Emulsify Design System](https://raw.githubusercontent.com/emulsify-ds/.github/6bd435be881bd820bddfa05d88905efe29176a0a/assets/images/header.png) # Emulsify WordPress @@ -159,6 +159,11 @@ The `.husky/pre-commit` hook already runs `npm run lint`, which delegates to thi Every pull request to the configured branches runs two visible fast readiness jobs: practical smoke checks and dedicated PHPCS/PHPStan analysis. Pull requests targeting `main` or `release-2.x` additionally run the MySQL-backed WordPress fixture and Whisk Storybook accessibility audit. The WordPress job builds and extracts `emulsify.zip`, confirms its packaged generator and Whisk source, and sets `WP_SMOKE_REQUIRED=1`, so missing WP-CLI/MySQL prerequisites or route render failures fail the job instead of producing a skip. It also audits six actual WordPress routes with explicit WCAG 2.2 AA axe tags and saves rendered HTML, results, and navigation evidence. The sole `target-size` exclusion reflects the unstyled parent; projects own target sizing and focus/helper styles. See [accessibility checks](docs/accessibility.md). +Practical checks block on PHP 8.3, 8.4, and 8.5. The WordPress fixture blocks on +the minimum 6.8 and on `latest`, resolved at run time with the downloaded +version logged; nightly coverage is advisory. The theme is tested through +WordPress 7.1 (the current stable fixture resolved to 7.1.3 on October 23, 2026). + The Whisk job selects and verifies the runner-installed Chrome with its sandbox enabled, copies an accessible CI-only story and Vite entry into the otherwise component-agnostic starter, runs seeded project tests, and then builds Storybook and runs axe. Storybook coverage supplements the actual WordPress route audit. Weekly scheduled runs repeat the WordPress fixture and Whisk accessibility audit. Manual GitHub Actions > `WordPress Theme Readiness` runs can select either extended fixture with the `wordpress_fixture` and `extended_checks` inputs. Local `release:check` still skips the database fixture when prerequisites are unavailable unless `WP_SMOKE_REQUIRED=1` is set. Release publishing also requires the full WordPress fixture path before semantic-release can publish. @@ -185,6 +190,12 @@ For Emulsify CLI integration, the standalone starter repository is `https://gith ## Documentation +This README, [UPGRADE.md](UPGRADE.md), and the guides in this repository are +authoritative for the WordPress 2.x workflow. The +[Emulsify documentation site](https://emulsify.info/docs) also covers older +tooling and other platforms; check a guide's platform and version before using +its commands. + - [Documentation index](docs/README.md) - [Generated-theme upgrade guide](UPGRADE.md) - [Upgrading from 1.x to 2.x](docs/upgrading-1x-to-2x.md) diff --git a/docs/README.md b/docs/README.md index add9c7b..26e5902 100644 --- a/docs/README.md +++ b/docs/README.md @@ -7,6 +7,7 @@ - [Parent and child theme architecture](parent-child-architecture.md) - [WP-CLI child theme generation](wp-cli-child-theme-generation.md) - [Generated child theme contract](generated-child-theme-contract.md) +- [Dependency audit: October 23, 2026](dependency-audit-2026-10-23.md) ## Authoring diff --git a/docs/dependency-audit-2026-10-23.md b/docs/dependency-audit-2026-10-23.md new file mode 100644 index 0000000..93a66f5 --- /dev/null +++ b/docs/dependency-audit-2026-10-23.md @@ -0,0 +1,76 @@ +# Dependency audit — October 23, 2026 + +The targeted root npm update reduces the audit from **10 vulnerable package +entries (1 critical, 8 high, 1 moderate)** to **7 (6 high, 1 moderate)**. +The root runtime audit, `npm audit --omit=dev`, reports zero findings. All +remaining entries belong to development-only release tooling; this audit does +not cover dependencies installed separately by generated child themes. + +## Compatible updates + +| Lockfile entry | Before → after | Dependency owner | +| --- | --- | --- | +| `handlebars` | 4.7.9 → 4.7.10 | `conventional-changelog-writer` | +| `js-yaml` | 4.3.0 → 4.3.2 | Root YAML contract checks and release tooling | +| `fast-uri` | 3.1.5 → 3.1.9 | `ajv` | +| `@actions/http-client` → `undici` | 6.27.0 → 6.29.1 | `@semantic-release/npm` → `@actions/core` | +| Root `undici` | 7.28.0 → 7.30.1 | `@semantic-release/github` | + +The direct `js-yaml` pin changes from `4.3.0` to `4.3.2`; its exact pin otherwise +prevents `npm update` from selecting the fix. All other manifest ranges stay +unchanged. There are no overrides, dependency major upgrades, engine changes, +or theme version changes. npm also removes a redundant optional peer copy of +`conventional-commits-filter` 6.0.1 from the lockfile. + +Fast URI 3.1.9 includes the fix for +[GHSA-hrr3-gc8f-f4qj](https://github.com/advisories/GHSA-hrr3-gc8f-f4qj), whose +affected range includes 3.1.7. The independently resolved Handlebars, JS-YAML, +Fast URI and Undici copies are absent from the final audit findings. + +## Remaining findings and ownership + +Every remaining finding is inside +`semantic-release` → `@semantic-release/npm` 13.1.5 → `npm` 11.18.0. npm bundles +these dependencies, so updating the independent packages above does not replace +them. + +| Bundled package | Installed version | Audit severity | Follow-up owner | +| --- | --- | --- | --- | +| `brace-expansion` | 5.0.7 | High | npm bundle; root maintainers can evaluate a separate npm lock update. | +| `http-cache-semantics` | 4.2.0 | High | npm bundle; root maintainers can evaluate a separate npm lock update. | +| `ip-address` | 10.2.0 | High | npm bundle; root maintainers can evaluate a separate npm lock update. | +| `postcss-selector-parser` | 7.1.4 | Moderate | npm bundle; root maintainers can evaluate a separate npm lock update. | +| `tar` | 7.5.19 | High | npm bundle; root maintainers can evaluate a separate npm lock update. | +| `undici` | 6.27.0 | High | npm bundle; root maintainers can evaluate a separate npm lock update. | + +The seventh entry is the propagated high finding on `npm` itself, via `tar`; +these are package entries, not seven independent vulnerabilities. The installed +`@semantic-release/npm` accepts npm `^11.6.2`, so a separate compatible npm update +can be assessed without changing the release plugin's major version. It was +outside this four-package update; no bundled leaf was replaced manually. + +Root `micromatch` 4.0.8 also depends on `braces` 3.0.3. Neither is reported by +this audit response. If that path is flagged by a future audit, the dependency +owners are `braces`/`micromatch` and their release-tooling consumers; a generated +theme's Core/Stylelint tree must be assessed separately. A clean runtime audit +does not establish that development tooling or generated projects are free of +security issues. + +## Reproduction + +Commands ran with Node 24.22.1 and npm 11.19.1: + +```sh +npm audit --json +# Update the existing exact js-yaml pin to 4.3.2 first. +npm update handlebars js-yaml fast-uri undici --ignore-scripts +npm ci --ignore-scripts +npm audit --json +npm audit --omit=dev --json +npm run test:generated-theme +``` + +The clean install succeeds. Both full audits exit 1 because findings remain; +the runtime audit exits 0. The generated-theme contract tests verify the YAML +guidance checks with the updated parser. Results are point-in-time registry +observations, and should be refreshed before the release. diff --git a/docs/release-notes-next.md b/docs/release-notes-next.md index 1ec4e17..7ad8cc0 100644 --- a/docs/release-notes-next.md +++ b/docs/release-notes-next.md @@ -1,6 +1,6 @@ # 2.1.0 Release Notes Draft -Status date: 2026-10-22 +Status date: 2026-10-23 ## Version strategy @@ -84,7 +84,9 @@ Title: `2.1.0` - The minimum WordPress version is now 6.8, matching the locked Timber 2.5.1 requirement. Sites on WordPress 6.7 must update WordPress before updating the - theme. The blocking fixture and `Tested up to` header now target 6.8. + theme. The minimum fixture remains blocking; the current stable release is + also blocking and `Tested up to` is 7.1 after validation on 7.1.3. Nightly is + advisory. Practical checks now block on PHP 8.3, 8.4, and 8.5. ### Fixed @@ -133,6 +135,15 @@ Title: `2.1.0` ### Internal +- Workflow actions use full SHA pins and Node 24 artifact uploads, maintained + by weekly GitHub Actions Dependabot updates. Validation credentials are + read-only; the publish dry run uses the publishing job's scoped token. + Project-board and contributor automation use pinned releases and corrected + token configuration. +- Compatible root lockfile updates fix the independently resolved Handlebars, + JS-YAML, Fast URI, and Undici findings. Remaining development-only findings + belong to semantic-release's bundled npm; see the + [October 23 audit](dependency-audit-2026-10-23.md). - Added `npm run test:twig-helpers` to characterize PHP and installed Core JavaScript helpers with exact HTML, attribute maps, and remaining context. Separate expectations preserve observed differences without changing runtime diff --git a/docs/release-process.md b/docs/release-process.md index 3c82487..4e517be 100644 --- a/docs/release-process.md +++ b/docs/release-process.md @@ -169,11 +169,36 @@ Every configured pull request runs two independently visible fast jobs: - `Practical theme readiness`: clean root npm installation, Composer validation, npm audits, the practical `pr:check` suite, and static `release:check` assertions. - `PHP coding standards and static analysis`: PHP 8.3, Composer development dependencies, PHPCS, and PHPStan through `npm run lint:php`. +The practical matrix blocks on PHP 8.3, 8.4, and 8.5. PHP 8.5 was promoted +after the locked Composer install, PHP analysis, and practical suite passed on +8.5.11. No platform-requirement bypass is used for these blocking jobs. + Pull requests targeting `main` or `release-2.x` additionally run the two extended jobs: - `WordPress fixture smoke`: the built installable ZIP, MySQL, WP-CLI, Timber, packaged generator/Whisk coverage, generated-child activation, block discovery, assets, and home/page/single/archive/search/author/404 rendering with `WP_SMOKE_REQUIRED=1`. - `Extended Whisk Storybook and a11y`: an explicit Chrome setup, a CI-only component seed, the Core 4 Vite and Storybook builds, and an axe audit of the discovered story. +The WordPress matrix blocks on the declared minimum (6.8) and `latest`. +WP-CLI resolves `latest` at run time and the fixture logs the actual downloaded +version. Nightly is advisory coverage for the next release. On October 23, +2026, the packaged fixture and all six rendered accessibility audits passed +on WordPress 7.1.3; `Tested up to` therefore names 7.1. Raise that header only +after a green fixture run for the newer stable line. The +`readiness-matrix-contract.cjs` check and its negative tests run in `pr:check` +and `release:check`, preventing required legs from becoming advisory or +silently bypassing fixture checks. + +Every action is pinned to a full commit SHA with a release-version comment; +weekly GitHub Actions Dependabot updates maintain those pins. Validation jobs +use read-only credentials and disable persisted checkout credentials. Only +the publishing job receives release writes. Its dry run runs immediately +before publishing because semantic-release verifies push permission even in +dry-run mode. Contributor automation separately receives repository-content +write permission and uses `GITHUB_TOKEN`; adding issues to the organization +project still requires the configured Projects token. See the +[dated dependency audit](dependency-audit-2026-10-23.md) for remaining +development-tool findings and their owners. + Weekly scheduled runs repeat both extended jobs. Manual dispatch runs the WordPress fixture when `wordpress_fixture` is enabled and the Whisk build/audit when `extended_checks` is enabled. The workflow concurrency group cancels superseded pull-request runs, and both extended jobs have explicit runtime limits. ## Merge and release gate @@ -189,7 +214,7 @@ The two fast pull-request jobs run automatically for every configured target bra Success means `Practical theme readiness`, `PHP coding standards and static analysis`, `WordPress fixture smoke`, and `Extended Whisk Storybook and a11y` pass. The fixture job installs WP-CLI, starts MySQL, sets `WP_SMOKE_REQUIRED=1`, and runs `npm run release:check` against the isolated WordPress fixture. Record the successful workflow run in the release PR before merging. -The semantic-release workflow remains release-gated. It runs release readiness, requires the full WordPress fixture smoke path with WP-CLI and MySQL, runs a semantic-release dry run, and then allows the final publish job only after the readiness job succeeds. Release publishing must not proceed when the full fixture path is skipped or failed. +The semantic-release workflow remains release-gated. Read-only release readiness requires the full WordPress fixture smoke path with WP-CLI and MySQL. After readiness succeeds, the publishing job runs a semantic-release dry run before the actual publish command. Release publishing must not proceed when the full fixture path is skipped or failed. ## License From 53dee9bcd743224b95187a1906d5e5af683f3e28 Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Fri, 23 Oct 2026 10:24:15 -0500 Subject: [PATCH 029/100] ci: make search audit independent of relevance ordering --- .github/scripts/wordpress-fixture-smoke.cjs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/scripts/wordpress-fixture-smoke.cjs b/.github/scripts/wordpress-fixture-smoke.cjs index f41483d..56658ef 100755 --- a/.github/scripts/wordpress-fixture-smoke.cjs +++ b/.github/scripts/wordpress-fixture-smoke.cjs @@ -903,7 +903,8 @@ async function renderRoutes(wpPath, content, baseUrl, port, themeSlug) { { name: 'page', url: pageUrl, expectedText: ['Smoke Page', 'Smoke page content'], currentMenuHref: pageUrl }, { name: 'single', url: singleUrl, expectedText: ['Smoke Post', 'Smoke post content'], currentMenuHref: singleUrl }, { name: 'archive', url: archiveUrl, expectedText: ['Smoke Category', 'Smoke Post'], currentMenuHref: archiveUrl }, - { name: 'search', url: `${baseUrl}/?s=Smoke`, expectedText: ['Search results for Smoke', 'Smoke Page'] }, + // A unique content phrase avoids depending on core's relevance ordering. + { name: 'search', url: `${baseUrl}/?s=Smoke%20post%20content`, expectedText: ['Search results for Smoke post content', 'Smoke Post'] }, { name: 'archive-page-2', url: `${archiveUrl}&paged=2`, From 19e18a3ecf382fb1a714fa99c082b2b368ec7c9a Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Fri, 23 Oct 2026 10:27:54 -0500 Subject: [PATCH 030/100] ci: give release test remotes an explicit default branch --- .github/scripts/semantic-release.test.cjs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/scripts/semantic-release.test.cjs b/.github/scripts/semantic-release.test.cjs index 7e41ef4..10a0c9c 100644 --- a/.github/scripts/semantic-release.test.cjs +++ b/.github/scripts/semantic-release.test.cjs @@ -15,7 +15,8 @@ function fixture(t, version = '2.1.0') { const remote = path.join(workRoot, 'origin.git'); fs.mkdirSync(repoRoot); const git = (...args) => execFileSync('git', args, { cwd: repoRoot, encoding: 'utf8', stdio: 'pipe' }).trim(); - git('init', '--bare', remote); + // Keep remote HEAD valid even when the runner defaults to another branch. + git('-c', 'init.defaultBranch=legacy', 'init', '--bare', '-b', 'main', remote); git('init', '-b', 'main'); git('-c', 'user.name=Contract Test', '-c', 'user.email=contract@example.invalid', 'commit', '--allow-empty', '-m', 'chore: initial release'); git('-c', 'user.name=Contract Test', '-c', 'user.email=contract@example.invalid', 'tag', '-a', '2.0.0', '-m', '2.0.0'); From 884e45f1440a6cb58faf26dd46b95bee6598c139 Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Fri, 23 Oct 2026 10:30:10 -0500 Subject: [PATCH 031/100] ci: expose resolved WordPress versions in release summaries --- .github/scripts/release-check.cjs | 1 + .github/scripts/wordpress-fixture-smoke.cjs | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/scripts/release-check.cjs b/.github/scripts/release-check.cjs index 7bf8b49..3d0e44d 100644 --- a/.github/scripts/release-check.cjs +++ b/.github/scripts/release-check.cjs @@ -1423,6 +1423,7 @@ function runStaticChecks() { ensure(wordpressFixtureJob.includes('- name: Build installable theme archive') && wordpressFixtureJob.includes('run: npm run build:dist'), 'WordPress fixture job should build the installable archive.'); ensure(wordpressFixtureJob.includes('WP_SMOKE_THEME_ARCHIVE: dist-artifact/emulsify.zip'), 'WordPress fixture job should test the built archive instead of the source tree.'); ensure(wordpressFixtureSmoke.includes("['core', 'version']") && wordpressFixtureSmoke.includes('WordPress version: requested') && wordpressFixtureSmoke.includes('resolved ${resolvedWordpressVersion}'), 'WordPress fixture smoke should log the actual downloaded core version, including latest and nightly resolution.'); + ensure(wordpressFixtureSmoke.includes('log(`WordPress ${resolvedWordpressVersion} (requested ${wordpressVersion}): Generated'), 'The fixture success summary must expose resolved WordPress versions in captured CI output.'); ensure(themeReadinessWorkflow.includes('extended_checks'), 'theme-readiness.yml should expose optional extended checks.'); ensure(extendedWhiskJob.includes(releaseTargetedPrGate), 'Whisk accessibility job should run on release-targeted pull requests.'); ensure(extendedWhiskJob.includes("github.event_name == 'schedule'"), 'Whisk accessibility job should run on the schedule.'); diff --git a/.github/scripts/wordpress-fixture-smoke.cjs b/.github/scripts/wordpress-fixture-smoke.cjs index 56658ef..ef9a095 100755 --- a/.github/scripts/wordpress-fixture-smoke.cjs +++ b/.github/scripts/wordpress-fixture-smoke.cjs @@ -1039,7 +1039,7 @@ async function main() { stopServer(); assertCleanDebugLog(wpPath); - log('Generated and activated a child without Twig templates, checked block discovery, loaded assets, rendered parent page fallbacks and child block extensions without changing surrounding bytes, rendered home, single, archive, search, author, and 404 routes, and followed search pagination to page 2 with a clean debug log.' + (auditAccessibility ? ' Six rendered routes passed the WCAG accessibility audit.' : '')); + log(`WordPress ${resolvedWordpressVersion} (requested ${wordpressVersion}): Generated and activated a child without Twig templates, checked block discovery, loaded assets, rendered parent page fallbacks and child block extensions without changing surrounding bytes, rendered home, single, archive, search, author, and 404 routes, and followed search pagination to page 2 with a clean debug log.` + (auditAccessibility ? ' Six rendered routes passed the WCAG accessibility audit.' : '')); dropDatabase(env); if (!keepFixture) { From 90ad7e3afe296eac317ad276adec5011ea7c2ba0 Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Fri, 23 Oct 2026 11:10:48 -0500 Subject: [PATCH 032/100] feat(media): add opt-in privacy and native video posters --- .github/scripts/media-embed-browser.cjs | 108 ++++++ .github/scripts/media-embed-smoke.php | 332 ++++++++++++++++++ .github/scripts/release-check.cjs | 5 +- .github/scripts/rendered-a11y.cjs | 15 +- .github/scripts/rendered-a11y.test.cjs | 4 +- .github/scripts/wordpress-fixture-smoke.cjs | 44 ++- README.md | 3 +- UPGRADE.md | 9 + docs/README.md | 3 +- docs/accessibility.md | 12 +- docs/media-embeds.md | 131 +++++++ docs/release-notes-next.md | 8 +- docs/release-process.md | 2 +- includes/Bootstrap.php | 2 + includes/Media/Embeds.php | 357 ++++++++++++++++++++ templates/partials/oembed-poster.twig | 38 +++ 16 files changed, 1061 insertions(+), 12 deletions(-) create mode 100644 .github/scripts/media-embed-browser.cjs create mode 100644 .github/scripts/media-embed-smoke.php create mode 100644 docs/media-embeds.md create mode 100644 includes/Media/Embeds.php create mode 100644 templates/partials/oembed-poster.twig diff --git a/.github/scripts/media-embed-browser.cjs b/.github/scripts/media-embed-browser.cjs new file mode 100644 index 0000000..5c8d57f --- /dev/null +++ b/.github/scripts/media-embed-browser.cjs @@ -0,0 +1,108 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { tooling } = require('./rendered-a11y.cjs'); + +// Use local responses when activating players: verify browser behavior without +// relying on provider availability, tracking, autoplay policies, or live videos. +async function verifyMediaEmbeds({ themeDir, url, outputDir }) { + const { puppeteer } = tooling(themeDir); + const browser = await puppeteer.launch({ headless: true }); + const evidence = []; + try { + for (const poster of ['neutral', 'provider']) { + for (const index of [0, 1]) { + const page = await browser.newPage(); + const requests = []; + const thumbnails = []; + let activated = false; + try { + await page.setJavaScriptEnabled(false); + await page.setRequestInterception(true); + page.on('request', (request) => { + const target = new URL(request.url()); + if (['www.youtube.com', 'www.youtube-nocookie.com', 'player.vimeo.com'].includes(target.hostname)) { + requests.push({ url: target.href, activated, referer: request.headers().referer }); + return request.respond({ status: 200, contentType: 'text/html', body: 'Fixture playerFixture player' }); + } + if (['i.ytimg.com', 'i.vimeocdn.com'].includes(target.hostname)) { + thumbnails.push({ url: target.href, referer: request.headers().referer }); + return request.respond({ status: 200, contentType: 'image/png', body: Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+a9WQAAAAASUVORK5CYII=', 'base64') }); + } + return request.continue(); + }); + const route = new URL(url); + route.searchParams.set('media_poster', poster); + const response = await page.goto(route.href, { waitUntil: 'networkidle0' }); + assert(response.ok()); + const frames = await page.$$('iframe[srcdoc]'); + assert.equal(frames.length, 2, 'Classic and block embeds must both use poster documents.'); + const frame = await frames[index].contentFrame(); + const document = await frame.evaluate(() => ({ + language: document.documentElement.lang, + title: document.title, + scripts: document.scripts.length, + link: document.querySelector('a').href, + referrer: document.querySelector('a').referrerPolicy, + height: innerHeight, + images: [...document.images].map((image) => ({ referrer: image.referrerPolicy, alt: image.alt })), + hiddenTitle: document.querySelector('.emulsify-oembed-visually-hidden')?.textContent, + })); + assert.equal(document.scripts, 0); + assert(document.language && document.title && document.hiddenTitle); + assert.equal(document.referrer, 'strict-origin-when-cross-origin'); + assert.equal(requests.length, 0, 'No player request is allowed before activation.'); + if (poster === 'neutral') assert.equal(thumbnails.length, 0, 'Neutral mode must not request provider thumbnails.'); + if (poster === 'provider' && index === 0) assert(thumbnails.length > 0, 'Provider mode must render the YouTube thumbnail.'); + for (const thumbnail of thumbnails) assert(!thumbnail.referer, 'Thumbnail requests must not disclose a referrer.'); + for (const image of document.images) assert(image.alt === '' && image.referrer === 'no-referrer'); + let focused = false; + for (let count = 0; count < 30 && !focused; count++) { + await page.keyboard.press('Tab'); + focused = await frame.evaluate(() => document.activeElement?.matches('a.emulsify-oembed-poster')); + } + assert(focused, 'Tab must reach the native play link inside the frame.'); + const styles = await frame.evaluate(() => { + const link = document.querySelector('a'); + const icon = document.querySelector('.emulsify-oembed-poster__play').getBoundingClientRect(); + const label = document.querySelector('.emulsify-oembed-poster__label').getBoundingClientRect(); + const ring = getComputedStyle(link, '::after'); + return { focused: link.matches(':focus-visible'), outline: ring.outlineStyle, content: ring.content, icon: { width: icon.width, top: icon.top, bottom: icon.bottom }, label: { top: label.top, bottom: label.bottom }, height: innerHeight }; + }); + assert(styles.focused && styles.outline !== 'none' && styles.content !== 'none', 'Keyboard focus must draw the ring above the thumbnail.'); + assert(styles.icon.top >= 0 && styles.label.bottom <= styles.height, 'Controls must fit inside the frame.'); + if (styles.height <= 160) assert(styles.icon.width <= 50, 'Short frames need a 3rem control plus its borders.'); + await page.screenshot({ path: path.join(outputDir, `media-${poster}-${index}-focus.png`) }); + fs.writeFileSync(path.join(outputDir, `media-${poster}-${index}.html`), await frame.content()); + activated = true; + await Promise.all([frame.waitForNavigation({ waitUntil: 'domcontentloaded' }), page.keyboard.press('Enter')]); + const player = new URL(frame.url()); + assert.equal(player.href, document.link, 'The play link must navigate the same iframe.'); + assert.equal(player.searchParams.get('autoplay'), '1'); + assert.equal(player.hostname, index === 0 ? 'www.youtube-nocookie.com' : 'player.vimeo.com'); + if (index === 1) { + assert.equal(player.searchParams.get('h'), 'fixturehash'); + assert.equal(player.searchParams.get('dnt'), '1'); + } + assert.equal(requests.length, 1); + assert(requests[0].activated && requests[0].referer === new URL(url).origin + '/', 'Activation must send only the containing origin as referrer.'); + const client = await page.createCDPSession(); + const history = await client.send('Page.getNavigationHistory'); + assert(history.currentIndex > 0); + await client.send('Page.navigateToHistoryEntry', { entryId: history.entries[history.currentIndex - 1].id }); + await frame.waitForSelector('a.emulsify-oembed-poster'); + assert.equal(frame.url(), 'about:srcdoc', 'Back must restore the poster document.'); + evidence.push({ poster, index, document, styles, requests, thumbnails, back: frame.url() }); + } finally { + await page.close(); + } + } + } + } finally { + fs.writeFileSync(path.join(outputDir, 'media-browser.json'), JSON.stringify(evidence, null, 2) + '\n'); + await browser.close(); + } + console.log('Media posters passed JavaScript-free keyboard activation, privacy, network/referrer, compact focus, and Back-history checks.'); +} + +module.exports = { verifyMediaEmbeds }; diff --git a/.github/scripts/media-embed-smoke.php b/.github/scripts/media-embed-smoke.php new file mode 100644 index 0000000..49f683b --- /dev/null +++ b/.github/scripts/media-embed-smoke.php @@ -0,0 +1,332 @@ +next_tag( 'IFRAME' ), 'Expected an iframe in transformed video markup.' ); + return $processor->get_attribute( $name ); +} + +/** + * Parses a srcdoc document without loading external resources. + * + * @param string $html HTML document. + * @return DOMDocument Parsed document. + */ +function emulsify_media_smoke_document( string $html ): DOMDocument { + $document = new DOMDocument(); + $previous = libxml_use_internal_errors( true ); + $loaded = $document->loadHTML( '' . $html, LIBXML_NONET | LIBXML_NOERROR | LIBXML_NOWARNING ); + libxml_clear_errors(); + libxml_use_internal_errors( $previous ); + emulsify_media_smoke_assert( $loaded, 'The poster must be a parseable HTML document.' ); + return $document; +} + +/** + * Builds local provider iframe markup with normal WordPress URL escaping. + * + * @param string $source Player URL. + * @param string $title Player title. + * @param int $height Frame height. + * @return string Provider markup. + */ +function emulsify_media_smoke_markup( string $source, string $title = 'Fixture video', int $height = 360 ): string { + return ''; +} + +$options = array(); +$options_hook = static function ( array $defaults ) use ( &$options ): array { + return array_merge( $defaults, $options ); +}; +$translation = static function ( string $translation, string $text, string $domain ): string { + return 'emulsify' === $domain && 'Play video' === $text ? 'Lire la vidéo' : $translation; +}; +$http_requests = 0; +$block_http = static function () use ( &$http_requests ) { + ++$http_requests; + return new WP_Error( 'emulsify_fixture_network', 'The media embed smoke blocks provider requests.' ); +}; +$fixtures = array( + 'youtube' => array( + 'url' => 'https://www.youtube.com/watch?v=abcdefghijk', + 'source' => 'https://www.youtube.com/embed/abcdefghijk?feature=oembed&x.y=a%20b&x.y=second&list[]=one&list[]=two&autoplay=0#chapter', + ), + 'vimeo' => array( + 'url' => 'https://vimeo.com/123456789/abc123', + 'source' => 'https://player.vimeo.com/video/123456789?h=abc123&x.y=a%20b&x.y=second&list[]=one&list[]=two&dnt=0#chapter', + ), +); +$service = new Embeds(); +$canonical = 'https://vimeo.com/123456789/abc123'; +$metadata_key = Embeds::METADATA_PREFIX . md5( $canonical ); +$original_data = get_transient( $metadata_key ); +$created_posts = array(); +$original_post = $GLOBALS['post'] ?? null; +$original_embed_post = $GLOBALS['wp_embed']->post_ID; +$child_dir = get_stylesheet_directory(); +$override = $child_dir . '/templates/partials/oembed-poster.twig'; +$original_file = is_file( $override ) ? file_get_contents( $override ) : null; +$created_dirs = array(); +$hostile_title = 'Fixture & "title"'; + +add_filter( 'emulsify_theme_media_embed_options', $options_hook, 999 ); +add_filter( 'gettext', $translation, 999, 3 ); +add_filter( 'pre_http_request', $block_http, 999 ); + +try { + emulsify_media_smoke_assert( false === apply_filters( 'oembed_dataparse', false, (object) array( 'type' => 'unsupported' ), 'https://video.example.test/unsupported' ), 'Unsupported oEmbed data must retain WordPress\'s false result.' ); + foreach ( $fixtures as $name => $fixture ) { + $html = " \n" . emulsify_media_smoke_markup( $fixture['source'] ) . "\n "; + emulsify_media_smoke_assert( apply_filters( 'embed_oembed_html', $html, $fixture['url'], array(), 0 ) === $html, 'Disabled options must preserve the exact ' . $name . ' bytes.' ); + } + + // Cached data comes from WordPress's oEmbed parse pipeline, including the + // unlisted video's h value rather than merging distinct videos by ID. + $metadata = (object) array( + 'thumbnail_url' => 'https://i.vimeocdn.com/video/emulsify-fixture.jpg', + 'title' => $hostile_title, + ); + $vimeo_html = emulsify_media_smoke_markup( $fixtures['vimeo']['source'], $hostile_title ); + emulsify_media_smoke_assert( $service->cache_oembed_data( $vimeo_html, $metadata, $canonical ) === $vimeo_html, 'Metadata capture must not edit the cached oEmbed HTML.' ); + $cached = get_transient( $metadata_key ); + emulsify_media_smoke_assert( is_array( $cached ) && $metadata->thumbnail_url === ( $cached['thumbnail_url'] ?? null ), 'Vimeo metadata must be cached under its unlisted video URL.' ); + + foreach ( $fixtures as $name => $fixture ) { + $html = emulsify_media_smoke_markup( $fixture['source'], $hostile_title ); + $privacy_url = 'youtube' === $name ? str_replace( 'www.youtube.com', 'www.youtube-nocookie.com', $fixture['source'] ) : str_replace( 'dnt=0', 'dnt=1', $fixture['source'] ); + $options = array( 'privacy_mode' => true ); + $private = apply_filters( 'embed_oembed_html', $html, $fixture['url'], array(), 0 ); + // The native src setter URL-encodes literal query brackets. Keep that + // security boundary while comparing every remaining query byte exactly. + emulsify_media_smoke_assert( esc_url_raw( $privacy_url ) === emulsify_media_smoke_attribute( $private, 'src' ), 'Privacy must preserve every unrelated query pair, Vimeo h, and fragment through WordPress URL normalization for ' . $name . '.' ); + emulsify_media_smoke_assert( null === emulsify_media_smoke_attribute( $private, 'srcdoc' ), 'Privacy alone must retain the real player for ' . $name . '.' ); + emulsify_media_smoke_assert( 'fixture-video' === emulsify_media_smoke_attribute( $private, 'class' ) && 'lazy' === emulsify_media_smoke_attribute( $private, 'loading' ), 'Privacy must preserve unrelated iframe attributes.' ); + + foreach ( array( false, true ) as $privacy ) { + foreach ( array( 'provider', 'neutral' ) as $poster ) { + $options = array( 'privacy_mode' => $privacy, 'click_to_load' => true, 'poster' => $poster ); + $output = apply_filters( 'embed_oembed_html', $html, $fixture['url'], array(), 0 ); + $srcdoc = emulsify_media_smoke_attribute( $output, 'srcdoc' ); + emulsify_media_smoke_assert( is_string( $srcdoc ) && '' !== $srcdoc, 'Click-to-load must use a srcdoc poster.' ); + $source = emulsify_media_smoke_attribute( $output, 'src' ); + emulsify_media_smoke_assert( null === $source || '' === $source || 'about:blank' === $source, 'A poster must not request a player before activation.' ); + $document = emulsify_media_smoke_document( $srcdoc ); + $xpath = new DOMXPath( $document ); + $anchors = $document->getElementsByTagName( 'a' ); + emulsify_media_smoke_assert( 1 === $anchors->length, 'The poster must have exactly one native play link.' ); + $anchor = $anchors->item( 0 ); + $expected = $privacy ? $privacy_url : $fixture['source']; + $expected = false !== strpos( $expected, 'autoplay=0' ) ? str_replace( 'autoplay=0', 'autoplay=1', $expected ) : str_replace( '#chapter', '&autoplay=1#chapter', $expected ); + emulsify_media_smoke_assert( $expected === $anchor->getAttribute( 'href' ), 'Play must enable autoplay while preserving query bytes and Vimeo h.' ); + emulsify_media_smoke_assert( 'strict-origin-when-cross-origin' === $anchor->getAttribute( 'referrerpolicy' ), 'Play links must use the strict origin referrer policy.' ); + emulsify_media_smoke_assert( ! $anchor->hasAttribute( 'target' ) || '_self' === $anchor->getAttribute( 'target' ), 'Play must navigate the same frame.' ); + emulsify_media_smoke_assert( false !== strpos( $anchor->textContent, 'Lire la vidéo' ) && false !== strpos( $anchor->textContent, $hostile_title ), 'Play labels must be translated and retain the safely escaped video title. Actual: ' . $anchor->textContent ); + emulsify_media_smoke_assert( 1 === $xpath->query( '//a//span[contains(@class, "visually-hidden")]' )->length, 'The complete video title must be visually hidden inside the accessible link label.' ); + emulsify_media_smoke_assert( get_bloginfo( 'language' ) === $document->documentElement->getAttribute( 'lang' ), 'The poster document must declare the WordPress language.' ); + emulsify_media_smoke_assert( 0 === $xpath->query( '//iframe | //script | //noscript | //@*[starts-with(name(), "on")]' )->length, 'Posters must contain no nested player, script, or injected event handler.' ); + $images = $document->getElementsByTagName( 'img' ); + emulsify_media_smoke_assert( ( 'provider' === $poster ? 1 : 0 ) === $images->length, 'Only provider posters may request thumbnails.' ); + if ( 'provider' === $poster ) { + emulsify_media_smoke_assert( '' === $images->item( 0 )->getAttribute( 'alt' ) && 'no-referrer' === $images->item( 0 )->getAttribute( 'referrerpolicy' ), 'Provider thumbnails must be decorative and omit the referrer.' ); + } else { + emulsify_media_smoke_assert( 0 === $xpath->query( '//*[@src] | //link | //object | //embed' )->length, 'Neutral posters must not request third-party resources.' ); + } + $styles = $document->getElementsByTagName( 'style' ); + emulsify_media_smoke_assert( 1 === $styles->length && 1 === preg_match( '/:focus(?:-visible)?\b[^}]*outline\s*:/s', $styles->item( 0 )->textContent ), 'The isolated poster needs an inline visible focus ring.' ); + emulsify_media_smoke_assert( 1 === preg_match( '/@media[^\{]*max-height\s*:\s*160px/i', $styles->item( 0 )->textContent ), 'Posters must supply compact controls for frames at most 160px high.' ); + $allow = emulsify_media_smoke_attribute( $output, 'allow' ); + foreach ( array( 'autoplay', 'fullscreen', 'picture-in-picture', 'encrypted-media' ) as $permission ) { + emulsify_media_smoke_assert( is_string( $allow ) && 1 === preg_match( '/(?:^|;\s*)' . preg_quote( $permission, '/' ) . '\s+[^;]*https:\/\/' . preg_quote( (string) wp_parse_url( $expected, PHP_URL_HOST ), '/' ) . '(?:\s|;|$)/', $allow ), 'The iframe must delegate ' . $permission . ' to the player origin.' ); + } + } + } + + $options = array(); + emulsify_media_smoke_assert( apply_filters( 'embed_oembed_html', $html, $fixture['url'], array(), 0 ) === $html, 'Changing options must affect cached oEmbed HTML immediately, without a cache flush.' ); + } + + $options = array( 'privacy_mode' => true, 'click_to_load' => true, 'poster' => 'neutral' ); + foreach ( $fixtures as $name => $fixture ) { + $untitled = ''; + $fallback = apply_filters( 'embed_oembed_html', $untitled, $fixture['url'], array(), 0 ); + $expected_title = 'youtube' === $name ? 'YouTube video' : 'Vimeo video'; + emulsify_media_smoke_assert( $expected_title === emulsify_media_smoke_attribute( $fallback, 'title' ), 'Untitled posters must give their outer iframe a translated provider fallback title.' ); + $fallback_document = emulsify_media_smoke_document( (string) emulsify_media_smoke_attribute( $fallback, 'srcdoc' ) ); + emulsify_media_smoke_assert( $expected_title === $fallback_document->getElementsByTagName( 'title' )->item( 0 )->textContent && false !== strpos( $fallback_document->getElementsByTagName( 'a' )->item( 0 )->textContent, $expected_title ), 'Untitled poster documents and play links must retain the same accessible fallback title.' ); + } + foreach ( array( + 'https://youtube.com.evil.example/embed/abcdefghijk', + 'https://evil.example/www.youtube.com/embed/abcdefghijk', + '//www.youtube.com/embed/abcdefghijk', + 'http://www.youtube.com/embed/abcdefghijk', + 'https://www.youtube.com@evil.example/embed/abcdefghijk', + 'https://user:pass@www.youtube.com/embed/abcdefghijk', + 'https://www.youtube.com:444/embed/abcdefghijk', + 'https://www.youtube.com/watch?v=abcdefghijk', + 'https://player.vimeo.com.evil.example/video/123456789', + 'https://video.example.test/embed/123456789', + ) as $source ) { + $html = emulsify_media_smoke_markup( $source ); + emulsify_media_smoke_assert( apply_filters( 'embed_oembed_html', $html, $fixtures['youtube']['url'], array(), 0 ) === $html, 'Unsupported and lookalike player URLs must remain untouched: ' . $source ); + } + $uppercase = apply_filters( 'embed_oembed_html', emulsify_media_smoke_markup( 'https://WWW.YOUTUBE.COM/embed/abcdefghijk' ), $fixtures['youtube']['url'], array(), 0 ); + $uppercase_doc = emulsify_media_smoke_document( (string) emulsify_media_smoke_attribute( $uppercase, 'srcdoc' ) ); + emulsify_media_smoke_assert( 'https://www.youtube-nocookie.com/embed/abcdefghijk?autoplay=1' === $uppercase_doc->getElementsByTagName( 'a' )->item( 0 )->getAttribute( 'href' ), 'Recognized hostnames must be case insensitive.' ); + foreach ( array( + '

      Extra markup

      ', + '', + '', + '', + '', + '', + '

      Not an iframe

      ', + ) as $html ) { + emulsify_media_smoke_assert( apply_filters( 'embed_oembed_html', $html, $fixtures['youtube']['url'], array(), 0 ) === $html, 'Malformed or compound HTML must pass through unchanged: ' . $html ); + } + + // Provider metadata is optional. A miss must return immediately and queue one + // bounded background lookup; cached failures must not queue on every render. + $options = array( 'click_to_load' => true, 'poster' => 'provider' ); + delete_transient( $metadata_key ); + wp_clear_scheduled_hook( 'emulsify_theme_refresh_vimeo_poster', array( $canonical ) ); + $missing = apply_filters( 'embed_oembed_html', $vimeo_html, $canonical, array(), 0 ); + emulsify_media_smoke_assert( 0 === emulsify_media_smoke_document( (string) emulsify_media_smoke_attribute( $missing, 'srcdoc' ) )->getElementsByTagName( 'img' )->length, 'Missing Vimeo metadata must fall back to a neutral poster.' ); + emulsify_media_smoke_assert( false !== wp_next_scheduled( 'emulsify_theme_refresh_vimeo_poster', array( $canonical ) ), 'Missing Vimeo metadata must schedule a background lookup.' ); + $first_cron = wp_next_scheduled( 'emulsify_theme_refresh_vimeo_poster', array( $canonical ) ); + apply_filters( 'embed_oembed_html', $vimeo_html, $canonical, array(), 0 ); + emulsify_media_smoke_assert( $first_cron === wp_next_scheduled( 'emulsify_theme_refresh_vimeo_poster', array( $canonical ) ), 'Repeated renders must not duplicate the metadata lookup.' ); + emulsify_media_smoke_assert( 0 === $http_requests, 'Rendering must never synchronously fetch provider metadata.' ); + wp_clear_scheduled_hook( 'emulsify_theme_refresh_vimeo_poster', array( $canonical ) ); + set_transient( $metadata_key, array(), HOUR_IN_SECONDS ); + apply_filters( 'embed_oembed_html', $vimeo_html, $canonical, array(), 0 ); + emulsify_media_smoke_assert( false === wp_next_scheduled( 'emulsify_theme_refresh_vimeo_poster', array( $canonical ) ), 'Negative metadata caches must prevent repeated lookup scheduling.' ); + delete_transient( $metadata_key ); + $service->refresh_vimeo_poster( $canonical ); + emulsify_media_smoke_assert( array() === get_transient( $metadata_key ), 'Provider failures must produce a bounded negative cache.' ); + + // Exercise WordPress's real content pipeline rather than only calling the + // filter: both a classic URL and a core/embed block must be transformed. + $provider_result = static function ( $result, string $url ) use ( $fixtures ) { + foreach ( $fixtures as $fixture ) { + if ( $url === $fixture['url'] ) { + return emulsify_media_smoke_markup( $fixture['source'] ); + } + } + return $result; + }; + add_filter( 'pre_oembed_result', $provider_result, 999, 2 ); + foreach ( $fixtures as $name => $fixture ) { + foreach ( array( 'classic', 'block' ) as $type ) { + $content = 'classic' === $type ? "\n" . $fixture['url'] . "\n" : '
      ' . "\n" . $fixture['url'] . "\n" . '
      '; + $post_id = wp_insert_post( array( 'post_title' => 'Media smoke ' . $name . ' ' . $type, 'post_content' => $content, 'post_status' => 'publish' ), true ); + emulsify_media_smoke_assert( ! is_wp_error( $post_id ), 'The media smoke must be able to seed a temporary post.' ); + $created_posts[] = $post_id; + $GLOBALS['post'] = get_post( $post_id ); + $GLOBALS['wp_embed']->post_ID = $post_id; + $options = array( 'privacy_mode' => true, 'click_to_load' => true, 'poster' => 'neutral' ); + $rendered = apply_filters( 'the_content', $content ); + emulsify_media_smoke_assert( is_string( emulsify_media_smoke_attribute( $rendered, 'srcdoc' ) ), 'The real ' . $type . ' content pipeline must transform ' . $name . ' embeds.' ); + $options = array(); + $disabled = apply_filters( 'the_content', $content ); + emulsify_media_smoke_assert( null === emulsify_media_smoke_attribute( $disabled, 'srcdoc' ) && $fixture['source'] === emulsify_media_smoke_attribute( $disabled, 'src' ), 'Disabling options must immediately restore the real ' . $type . ' cached player without a cache flush.' ); + } + } + remove_filter( 'pre_oembed_result', $provider_result, 999 ); + + // A project can override this document through the same child-first Twig + // namespace as other parent fallbacks. Preserve any existing project file. + foreach ( array( $child_dir . '/templates', $child_dir . '/templates/partials' ) as $directory ) { + if ( ! is_dir( $directory ) ) { + emulsify_media_smoke_assert( mkdir( $directory ), 'Unable to create the temporary child template directory.' ); + $created_dirs[] = $directory; + } + } + $custom = 'Child poster

      Child override

      '; + emulsify_media_smoke_assert( false !== file_put_contents( $override, $custom ), 'Unable to write the temporary child poster override.' ); + $options = array( 'click_to_load' => true, 'poster' => 'neutral' ); + $output = apply_filters( 'embed_oembed_html', emulsify_media_smoke_markup( $fixtures['youtube']['source'] ), $fixtures['youtube']['url'], array(), 0 ); + emulsify_media_smoke_assert( $custom === emulsify_media_smoke_attribute( $output, 'srcdoc' ), 'Child @templates poster overrides must replace the parent fallback.' ); + + // Invalidate the already-compiled child template after changing it in this + // request. A normal request starts with the project's template already saved. + $refresh_template = static function ( \Twig\Environment $environment ): \Twig\Environment { + $environment->removeCache( '@templates/partials/oembed-poster.twig' ); + return $environment; + }; + emulsify_media_smoke_assert( false !== file_put_contents( $override, '{# Intentionally empty project override. #}' ), 'Unable to write the temporary empty poster override.' ); + add_filter( 'timber/twig', $refresh_template, 999 ); + $empty_rejected = false; + try { + apply_filters( 'embed_oembed_html', emulsify_media_smoke_markup( $fixtures['youtube']['source'] ), $fixtures['youtube']['url'], array(), 0 ); + } catch ( UnexpectedValueException $exception ) { + $empty_rejected = true; + } finally { + remove_filter( 'timber/twig', $refresh_template, 999 ); + file_put_contents( $override, $custom ); + } + emulsify_media_smoke_assert( $empty_rejected, 'An empty project poster must fail closed instead of silently loading a third-party player.' ); + + echo "PASS media embeds: off defaults, raw parameter preservation, privacy, translated srcdoc posters, safe URLs, asynchronous metadata, classic/block filters, child overrides, and fail-closed poster errors.\n"; +} finally { + remove_filter( 'emulsify_theme_media_embed_options', $options_hook, 999 ); + remove_filter( 'gettext', $translation, 999 ); + remove_filter( 'pre_http_request', $block_http, 999 ); + if ( isset( $provider_result ) ) { + remove_filter( 'pre_oembed_result', $provider_result, 999 ); + } + $GLOBALS['post'] = $original_post; + $GLOBALS['wp_embed']->post_ID = $original_embed_post; + foreach ( $created_posts as $post_id ) { + wp_delete_post( $post_id, true ); + } + wp_clear_scheduled_hook( 'emulsify_theme_refresh_vimeo_poster', array( $canonical ) ); + if ( false === $original_data ) { + delete_transient( $metadata_key ); + } else { + set_transient( $metadata_key, $original_data, DAY_IN_SECONDS ); + } + if ( null !== $original_file ) { + file_put_contents( $override, $original_file ); + } elseif ( is_file( $override ) ) { + unlink( $override ); + } + foreach ( array_reverse( $created_dirs ) as $directory ) { + rmdir( $directory ); + } +} diff --git a/.github/scripts/release-check.cjs b/.github/scripts/release-check.cjs index 3d0e44d..382ef9a 100644 --- a/.github/scripts/release-check.cjs +++ b/.github/scripts/release-check.cjs @@ -324,6 +324,8 @@ function runStaticChecks() { '.github/scripts/fallback-navigation-smoke.php', '.github/scripts/rendered-a11y.cjs', '.github/scripts/rendered-a11y.test.cjs', + '.github/scripts/media-embed-smoke.php', + '.github/scripts/media-embed-browser.cjs', '.github/scripts/docs-command-check.cjs', '.github/scripts/pr-validation.cjs', '.github/scripts/semantic-release.cjs', @@ -1096,7 +1098,8 @@ function runStaticChecks() { ensure(wordpressFixtureSmoke.includes('archiveCount < 12') && wordpressFixtureSmoke.includes("['option', 'update', 'posts_per_page', '1']"), 'Rendered archive audit needs at least 12 posts and one post per page.'); const guide = readFile('docs/accessibility.md'); ensure(guide.includes('target-size') && guide.includes('screen-reader-text') && guide.includes('focus'), 'Accessibility guide must document the sole rule exception and project styling responsibilities.'); - return 'Six rendered routes use sandboxed Chrome, full WCAG tags, strict navigation assertions, and retained HTML/axe reports; only target-size is excluded.'; + ensure(wordpressFixtureSmoke.includes("name: 'media-embeds'") && wordpressFixtureSmoke.includes('media-embed-smoke.php') && wordpressFixtureSmoke.includes('verifyMediaEmbeds'), 'The fixture must test media transforms and audit the rendered YouTube/Vimeo posters.'); + return 'Seven rendered routes use sandboxed Chrome, full WCAG tags, strict navigation/media assertions, and retained HTML/axe reports; only target-size is excluded.'; }); runStaticCheck('Generated child theme documentation', () => { diff --git a/.github/scripts/rendered-a11y.cjs b/.github/scripts/rendered-a11y.cjs index 6058017..3ebfcdb 100644 --- a/.github/scripts/rendered-a11y.cjs +++ b/.github/scripts/rendered-a11y.cjs @@ -121,10 +121,14 @@ async function auditRenderedPages({ themeDir, routes, outputDir }) { try { for (const route of routes) { const page = await browser.newPage(); - const report = { name: route.name, requestedUrl: route.url, wcagTags, disabledRules, browserErrors: [], axe: null }; + const report = { name: route.name, requestedUrl: route.url, wcagTags, disabledRules, browserErrors: [], externalRequests: [], axe: null }; let html = ''; reports.push(report); page.on('pageerror', (error) => report.browserErrors.push(error.message)); + page.on('request', (request) => { + const url = new URL(request.url()); + if (['http:', 'https:'].includes(url.protocol) && url.origin !== new URL(route.url).origin) report.externalRequests.push(url.href); + }); try { await page.setViewport({ width: 1280, height: 900 }); const response = await page.goto(route.url, { waitUntil: 'networkidle0', timeout: 30000 }); @@ -133,7 +137,12 @@ async function auditRenderedPages({ themeDir, routes, outputDir }) { report.finalUrl = page.url(); assert(response?.ok(), `${route.name}: navigation returned HTTP ${report.status ?? 'no response'}.`); report.rendered = await renderedEvidence(page, route.selector || 'main'); - await page.addScriptTag({ path: axePath }); + if (route.mediaEmbeds !== undefined) { + assert.equal(await page.$$eval('iframe[srcdoc]', (frames) => frames.length), route.mediaEmbeds, `${route.name}: expected poster documents for every provider.`); + assert.equal(report.externalRequests.length, 0, `${route.name}: neutral posters must make no third-party requests.`); + } + // Same-origin srcdoc posters need axe in their documents as well. + for (const frame of page.frames()) await frame.addScriptTag({ path: axePath }); report.axe = await page.evaluate((tags) => axe.run(document, { runOnly: { type: 'tag', values: tags }, rules: { 'target-size': { enabled: false } }, @@ -176,4 +185,4 @@ if (require.main === module) { } } -module.exports = { auditRenderedPages, wcagTags, disabledRules }; +module.exports = { auditRenderedPages, wcagTags, disabledRules, tooling }; diff --git a/.github/scripts/rendered-a11y.test.cjs b/.github/scripts/rendered-a11y.test.cjs index f2150c9..944d38f 100644 --- a/.github/scripts/rendered-a11y.test.cjs +++ b/.github/scripts/rendered-a11y.test.cjs @@ -26,6 +26,7 @@ test('rendered accessibility gate rejects HTTP, content, axe, pagination, and me '/axe-violation': clean.replace('', ''), '/bad-pagination': clean.replace('
      ', ''), '/bad-menu': clean.replace('
      • ', ''), }; const server = http.createServer((request, response) => { response.writeHead(request.url === '/http-error' ? 500 : 200, { 'Content-Type': 'text/html; charset=utf-8' }); @@ -46,7 +47,7 @@ test('rendered accessibility gate rejects HTTP, content, axe, pagination, and me })); await assert.rejects(auditRenderedPages({ themeDir, routes, outputDir }), (error) => { assert(error instanceof AggregateError); - assert.equal(error.errors.length, 5, 'Every failing route must be reported, including routes after the first failure.'); + assert.equal(error.errors.length, 6, 'Every failing route must be reported, including routes after the first failure.'); return true; }); const reports = Object.fromEntries(routes.map(({ name }) => { @@ -62,6 +63,7 @@ test('rendered accessibility gate rejects HTTP, content, axe, pagination, and me assert.match(reports['http-error'].error, /HTTP 500/); assert.match(reports['missing-content'].error, /missing expected rendered content/); assert(reports['axe-violation'].axe.violations.some(({ id }) => id === 'image-alt')); + assert(reports['bad-media'].axe.violations.some(({ id }) => id === 'image-alt'), 'Axe must audit the document inside srcdoc too.'); assert.match(reports['axe-violation'].error, /accessibility violations: image-alt/); assert.match(reports['bad-pagination'].error, /exactly one current pagination item/); assert.match(reports['bad-menu'].error, /exactly one current primary-menu item/); diff --git a/.github/scripts/wordpress-fixture-smoke.cjs b/.github/scripts/wordpress-fixture-smoke.cjs index ef9a095..c98ede7 100755 --- a/.github/scripts/wordpress-fixture-smoke.cjs +++ b/.github/scripts/wordpress-fixture-smoke.cjs @@ -593,6 +593,41 @@ function createFixtureContent(wpPath) { '--post_content=Smoke post content', '--porcelain', ]); + const mediaPageId = wp(wpPath, [ + 'post', 'create', '--post_type=page', '--post_status=publish', + '--post_title=Media embed fixture', '--post_name=media-embeds', + '--post_content=[embed]https://www.youtube.com/watch?v=M7lc1UVf-VE[/embed]\n\n\n
        \nhttps://vimeo.com/76979871/fixturehash\n
        \n', + '--porcelain', + ]); + fs.writeFileSync(path.join(wpPath, 'wp-content', 'mu-plugins', 'emulsify-media-fixture.php'), `'; + } + if ( 'https://vimeo.com/76979871/fixturehash' === $url ) { + return ''; + } + return $result; +}, 10, 2 ); +add_filter( 'emulsify_theme_media_embed_options', function ( $options ) { + $poster = isset( $_GET['media_poster'] ) && 'provider' === $_GET['media_poster'] ? 'provider' : 'neutral'; + return is_page( 'media-embeds' ) ? array( 'privacy_mode' => true, 'click_to_load' => true, 'poster' => $poster ) : $options; +} ); +add_action( 'template_redirect', function () { + if ( is_page( 'media-embeds' ) ) { + header( "Content-Security-Policy: frame-src 'self' https://www.youtube.com https://www.youtube-nocookie.com https://player.vimeo.com; img-src 'self' https://i.ytimg.com https://i.vimeocdn.com data:; style-src 'self' 'unsafe-inline'" ); + header( 'Referrer-Policy: no-referrer' ); + } +} ); +`); + wp(wpPath, ['eval', String.raw` +apply_filters( 'oembed_dataparse', + '', + (object) array( 'type' => 'video', 'thumbnail_url' => 'https://i.vimeocdn.com/video/fixture_640.jpg', 'title' => 'Vimeo fixture' ), + 'https://vimeo.com/76979871/fixturehash' +); +`]); const authorId = wp(wpPath, [ 'user', 'create', @@ -648,7 +683,7 @@ for ( $number = 1; $number <= 11; $number++ ) { wp(wpPath, ['menu', 'item', 'add-term', menuId, 'category', categoryId]); wp(wpPath, ['menu', 'location', 'assign', menuId, 'primary']); - return { authorId, categoryId, pageId, postId }; + return { authorId, categoryId, pageId, postId, mediaPageId }; } function getAvailablePort() { @@ -855,6 +890,7 @@ async function renderRoutes(wpPath, content, baseUrl, port, themeSlug) { ], }, { name: 'archive', path: `/?cat=${content.categoryId}`, includes: ['Smoke Category', 'Smoke Post'] }, + { name: 'media-embeds', path: `/?page_id=${content.mediaPageId}`, includes: ['Media embed fixture', 'srcdoc=', 'YouTube fixture', 'Vimeo fixture'] }, { name: 'search', path: '/?s=Smoke', includes: ['Search results for Smoke', 'Smoke Post'] }, { name: 'author', path: `/?author=${content.authorId}`, includes: ['Archive of Smoke Author', 'Smoke Post'] }, { name: '404', path: '/?p=9999999', status: 404, includes: ['Page not found'] }, @@ -903,6 +939,7 @@ async function renderRoutes(wpPath, content, baseUrl, port, themeSlug) { { name: 'page', url: pageUrl, expectedText: ['Smoke Page', 'Smoke page content'], currentMenuHref: pageUrl }, { name: 'single', url: singleUrl, expectedText: ['Smoke Post', 'Smoke post content'], currentMenuHref: singleUrl }, { name: 'archive', url: archiveUrl, expectedText: ['Smoke Category', 'Smoke Post'], currentMenuHref: archiveUrl }, + { name: 'media-embeds', url: `${baseUrl}/?page_id=${content.mediaPageId}`, expectedText: 'Media embed fixture', mediaEmbeds: 2 }, // A unique content phrase avoids depending on core's relevance ordering. { name: 'search', url: `${baseUrl}/?s=Smoke%20post%20content`, expectedText: ['Search results for Smoke post content', 'Smoke Post'] }, { @@ -916,6 +953,8 @@ async function renderRoutes(wpPath, content, baseUrl, port, themeSlug) { fs.mkdirSync(outputDir, { recursive: true }); fs.writeFileSync(path.join(outputDir, 'routes.json'), `${JSON.stringify(routes, null, 2)}\n`); await auditRenderedPages({ themeDir, outputDir, routes }); + const { verifyMediaEmbeds } = require('./media-embed-browser.cjs'); + await verifyMediaEmbeds({ themeDir, url: `${baseUrl}/?page_id=${content.mediaPageId}`, outputDir }); log(`Rendered accessibility reports: ${outputDir}`); } } @@ -1029,6 +1068,7 @@ async function main() { runNativeBlockDiscovery(wpPath); installAcfStub(wpPath); runAcfDiscoveryWithStub(wpPath); + wp(wpPath, ['eval-file', path.join(repoRoot, '.github/scripts/media-embed-smoke.php')]); wp(wpPath, [ 'eval', 'if ( ! class_exists( "\\\\Timber\\\\Timber" ) ) { fwrite( STDERR, "Timber is not loaded.\\n" ); exit( 1 ); }', @@ -1039,7 +1079,7 @@ async function main() { stopServer(); assertCleanDebugLog(wpPath); - log(`WordPress ${resolvedWordpressVersion} (requested ${wordpressVersion}): Generated and activated a child without Twig templates, checked block discovery, loaded assets, rendered parent page fallbacks and child block extensions without changing surrounding bytes, rendered home, single, archive, search, author, and 404 routes, and followed search pagination to page 2 with a clean debug log.` + (auditAccessibility ? ' Six rendered routes passed the WCAG accessibility audit.' : '')); + log(`WordPress ${resolvedWordpressVersion} (requested ${wordpressVersion}): Generated and activated a child without Twig templates, checked block discovery, loaded assets, rendered parent page fallbacks and child block extensions without changing surrounding bytes, rendered home, single, archive, search, author, 404, and media routes, and followed search pagination to page 2 with a clean debug log.` + (auditAccessibility ? ' Seven rendered routes passed the WCAG accessibility audit.' : '')); dropDatabase(env); if (!keepFixture) { diff --git a/README.md b/README.md index 00624c0..a2f19e5 100644 --- a/README.md +++ b/README.md @@ -157,7 +157,7 @@ The `.husky/pre-commit` hook already runs `npm run lint`, which delegates to thi | `npm run build:dist` | Build the installable `dist-artifact/emulsify.zip` release archive. | | `npm run publish-test -- --no-ci` | Run a local semantic-release dry run. | -Every pull request to the configured branches runs two visible fast readiness jobs: practical smoke checks and dedicated PHPCS/PHPStan analysis. Pull requests targeting `main` or `release-2.x` additionally run the MySQL-backed WordPress fixture and Whisk Storybook accessibility audit. The WordPress job builds and extracts `emulsify.zip`, confirms its packaged generator and Whisk source, and sets `WP_SMOKE_REQUIRED=1`, so missing WP-CLI/MySQL prerequisites or route render failures fail the job instead of producing a skip. It also audits six actual WordPress routes with explicit WCAG 2.2 AA axe tags and saves rendered HTML, results, and navigation evidence. The sole `target-size` exclusion reflects the unstyled parent; projects own target sizing and focus/helper styles. See [accessibility checks](docs/accessibility.md). +Every pull request to the configured branches runs two visible fast readiness jobs: practical smoke checks and dedicated PHPCS/PHPStan analysis. Pull requests targeting `main` or `release-2.x` additionally run the MySQL-backed WordPress fixture and Whisk Storybook accessibility audit. The WordPress job builds and extracts `emulsify.zip`, confirms its packaged generator and Whisk source, and sets `WP_SMOKE_REQUIRED=1`, so missing WP-CLI/MySQL prerequisites or route render failures fail the job instead of producing a skip. It also audits seven actual WordPress routes, including YouTube and Vimeo posters, with explicit WCAG 2.2 AA axe tags and saves rendered HTML, results, and navigation evidence. The sole `target-size` exclusion reflects the unstyled parent; projects own target sizing and focus/helper styles. See [accessibility checks](docs/accessibility.md). Practical checks block on PHP 8.3, 8.4, and 8.5. The WordPress fixture blocks on the minimum 6.8 and on `latest`, resolved at run time with the downloaded @@ -204,6 +204,7 @@ its commands. - [Parent and child theme architecture](docs/parent-child-architecture.md) - [Timber and Twig authoring](docs/timber-and-twig-authoring.md) - [Accessibility checks and project responsibilities](docs/accessibility.md) +- [Privacy and click-to-load media embeds](docs/media-embeds.md) - [Emulsify Core 4 and Vite workflow](docs/core-4-vite-workflow.md) - [Design token integration](docs/design-token-integration.md) - [Component recipes](docs/component-recipes.md) diff --git a/UPGRADE.md b/UPGRADE.md index 172c6f9..9f23bc4 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -6,6 +6,15 @@ then decide which starter changes to adopt in each generated project. ## Upgrading from 2.0.0 to 2.1.0 +YouTube and Vimeo oEmbeds gain optional privacy mode and JavaScript-free +click-to-load posters. Both are off by default; enable them with the +`emulsify_theme_media_embed_options` filter. Existing child themes inherit the +runtime and the parent poster fallback without copying starter files. Review +your Content Security Policy before enabling click to load: its `srcdoc` +document inherits the page policy, including restrictions on inline styles. +Projects can override `templates/partials/oembed-poster.twig`. See +[media embeds](docs/media-embeds.md) for the options and override contract. + Argumentless Twig `bem()` and `add_attributes()` now read only inherited `attributes`, rather than interpreting unrelated Timber context variables as attributes or BEM options. Pass intended values explicitly if a template relied diff --git a/docs/README.md b/docs/README.md index 26e5902..61fb092 100644 --- a/docs/README.md +++ b/docs/README.md @@ -14,6 +14,7 @@ - [Timber and Twig authoring](timber-and-twig-authoring.md) - [Parent Twig extension points](template-extension.md) - [Accessibility checks and project responsibilities](accessibility.md) +- [Privacy and click-to-load media embeds](media-embeds.md) - [Component recipes](component-recipes.md) - [Emulsify Core 4 and Vite workflow](core-4-vite-workflow.md) - [Design token integration](design-token-integration.md) @@ -38,4 +39,4 @@ ## Continuous integration -Every configured pull request runs practical smoke coverage and a dedicated PHPCS/PHPStan job. Pull requests targeting `main` or `release-2.x` additionally run the required MySQL/WP-CLI WordPress rendering fixture and a CI-seeded Whisk Storybook accessibility audit. The WordPress job also audits six rendered routes with explicit WCAG 2.2 AA tags and retains per-route evidence; see [accessibility checks](accessibility.md) for the sole target-size exception and project responsibilities. Weekly scheduled runs repeat both extended fixtures. Manual readiness runs use the `wordpress_fixture` and `extended_checks` inputs to select those extended jobs. The release process documents commands, route coverage, and merge-gate details. +Every configured pull request runs practical smoke coverage and a dedicated PHPCS/PHPStan job. Pull requests targeting `main` or `release-2.x` additionally run the required MySQL/WP-CLI WordPress rendering fixture and a CI-seeded Whisk Storybook accessibility audit. The WordPress job also audits seven rendered routes, including YouTube and Vimeo posters, with explicit WCAG 2.2 AA tags and retains per-route evidence; see [accessibility checks](accessibility.md) for the sole target-size exception and project responsibilities. Weekly scheduled runs repeat both extended fixtures. Manual readiness runs use the `wordpress_fixture` and `extended_checks` inputs to select those extended jobs. The release process documents commands, route coverage, and merge-gate details. diff --git a/docs/accessibility.md b/docs/accessibility.md index 9adc983..a421b87 100644 --- a/docs/accessibility.md +++ b/docs/accessibility.md @@ -54,8 +54,9 @@ The fixture uses plain permalinks and audits these anonymous routes: | Page | `/?page_id=` | | Single post | `/?p=` | | Category archive | `/?cat=` | -| Search | `/?s=Smoke` | +| Search | `/?s=Smoke%20post%20content` | | Category archive, page 2 | `/?cat=&paged=2` | +| Media embeds | `/?page_id=` | During these audits, `posts_per_page` is 1 and at least 12 posts belong to the category. The second archive page therefore exercises the actual current page, @@ -72,6 +73,15 @@ Current-state counts are scoped to each navigation region: a menu and pager may each correctly have a current item. The fallback marks current menu items only, leaving ancestors unmarked. +The media page opts into privacy mode and neutral click-to-load posters for a +classic YouTube embed and a Vimeo `core/embed` block. Axe also runs inside both +`srcdoc` documents, and neutral posters must make no third-party requests. +Separate browser checks disable page JavaScript, activate each player by +keyboard inside the same frame, preserve Vimeo's unlisted `h` parameter, and +check focus visibility, compact controls, thumbnail referrers, and Back history. +The opt-in poster's styles live only inside its iframe document; see +[media embeds](media-embeds.md) for the options and CSP requirements. + Numbered pagination links include a translated `Page ` prefix in a `span.screen-reader-text`, giving a link such as `Page 2` an explicit name. Only linked page numbers receive the prefix. The current-page span, ellipsis, diff --git a/docs/media-embeds.md b/docs/media-embeds.md new file mode 100644 index 0000000..d0ed9b3 --- /dev/null +++ b/docs/media-embeds.md @@ -0,0 +1,131 @@ +# Media embeds + +The parent theme can apply privacy mode and a click-to-load poster to WordPress +YouTube and Vimeo oEmbeds. Both options are off by default, so existing embeds +keep their original HTML. Other providers, unsupported player URLs, and +malformed embed HTML are left untouched. +Embeds with an existing `srcdoc` or `sandbox` attribute also remain unchanged, +preserving another integration's document and restrictions. + +## Options + +Use the `emulsify_theme_media_embed_options` filter in the child theme or a +project plugin. There is no settings screen: + +```php +add_filter( + 'emulsify_theme_media_embed_options', + function ( array $options ): array { + $options['privacy_mode'] = true; + $options['click_to_load'] = true; + $options['poster'] = 'neutral'; + + return $options; + } +); +``` + +| Option | Default | Behavior | +| --- | --- | --- | +| `privacy_mode` | `false` | Uses YouTube's `www.youtube-nocookie.com` player host and adds Vimeo's `dnt=1`. Other parameters remain intact, including Vimeo's `h` for unlisted videos. | +| `click_to_load` | `false` | Replaces the player with a native **Play video** link that opens the player inside the same frame with `autoplay=1`, without JavaScript. | +| `poster` | `'provider'` | Uses a provider thumbnail when available; `'neutral'` omits third-party images. This option takes effect only when click to load is enabled. | + +Privacy mode by itself still loads the player when the page is viewed. Click to +load prevents a visitor's browser from requesting the player before activation. +Provider posters still contact the thumbnail host before activation, revealing +the visitor's network address. Choose a neutral poster to avoid that image +request too. The provider receives data once the player loads, and its cookies, +consent behavior, and browser autoplay restrictions still apply. + +The parent transforms the `embed_oembed_html` output at render time, after +WordPress retrieves its cached oEmbed HTML. This covers classic-content embeds +and `core/embed` blocks. Changing these options does not require flushing the +oEmbed cache. Hand-written iframe HTML that bypasses WordPress oEmbed is outside +this filter. + +YouTube posters use the video's provider thumbnail. Vimeo posters use cached +`thumbnail_url` metadata captured from WordPress oEmbed responses. On a cache +miss, rendering immediately falls back to a neutral poster and schedules a +background WP-Cron lookup; it does not wait for Vimeo. Successful metadata is +cached for one day, and failed lookups for one hour. If WP-Cron is disabled or +delayed, the neutral poster still provides a working play link. These +server-side metadata requests do not expose the visitor's browser to the +provider. +WordPress still performs its normal oEmbed lookup when its HTML cache is cold. + +## Poster behavior and overrides + +The poster is a complete iframe `srcdoc` document rendered through +`@templates/partials/oembed-poster.twig`. A child can add +`templates/partials/oembed-poster.twig` to replace that document. To extend the +document, copy and adapt the fallback, or refer to its parent copy through +`@emulsify-tpl/partials/oembed-poster.twig`; using the same `@templates` path +from the override would resolve back to itself. See the +[template namespace contract](template-extension.md). + +The template receives a `media_embed` map: + +| Variable | Contents | +| --- | --- | +| `player_url` | Validated player URL with `autoplay=1` and the selected privacy behavior. | +| `title` | Video title from the iframe or cached Vimeo metadata, falling back to a translated provider label; used in the document and hidden link label. | +| `provider` | `YouTube` or `Vimeo`. | +| `thumbnail_url` | HTTPS image URL, or an empty string for a neutral poster. | +| `language_code` | Current WordPress locale formatted for the document's `lang`. | +| `play_label` | Translated `Play video` text. | + +The fallback supplies its own inline CSS inside the frame. It adds no stylesheet +to the surrounding page. Its translated play link has a visible focus ring, a +visually hidden video title, and a document language. Controls become compact in +frames 160 pixels high or shorter. Keep these accessibility details and Twig +autoescaping in an override; provider titles and URLs are not trusted HTML. +An invalid or empty poster template raises a rendering error. Fix or remove +that override before enabling click to load; an error does not silently fall +back to requesting the provider's player. + +The link uses `referrerpolicy="strict-origin-when-cross-origin"` so the player +receives the site's origin without its page path or query. Thumbnail images use +`referrerpolicy="no-referrer"`. The containing iframe permits autoplay, +fullscreen, picture-in-picture, and encrypted media for the recognized player +origins, including after the poster navigates the frame. Activation adds an +iframe navigation to browser history; the first Back returns to the poster. + +## Content Security Policy + +A `srcdoc` document inherits the embedding page's Content Security Policy, as +described by the [CSP inheritance rules](https://www.w3.org/TR/CSP/). +Deployments must account for both the poster and the player: + +- Include `'self'` in `frame-src` for the initial blank/`srcdoc` frame, alongside + the selected YouTube player host: + `https://www.youtube-nocookie.com` with privacy mode, or + `https://www.youtube.com` without it. Allow `https://player.vimeo.com` for + Vimeo. Existing accepted `https://youtube.com` embeds also need that origin + when privacy mode is off. +- Provider posters need their thumbnail hosts in `img-src`, typically + `https://i.ytimg.com` for YouTube and the host of Vimeo's cached + `thumbnail_url`. Neutral posters need no thumbnail host. +- The fallback's inline ` + + +
        + {% if media_embed.thumbnail_url %}{% endif %} + + {{ media_embed.play_label }}{% if media_embed.title %}: {{ media_embed.title }}{% endif %} + + + From c7171b4e47d1b41a47175f20ce1cc0d6eda2c2f7 Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Fri, 23 Oct 2026 11:25:27 -0500 Subject: [PATCH 033/100] docs: record Drupal 7.3 sync and finalize 2.1 release guidance --- UPGRADE.md | 267 ++++++++++++++++-------------- docs/README.md | 6 +- docs/release-notes-next.md | 294 ++++++++++++++++------------------ docs/sister-project-parity.md | 61 +++++++ 4 files changed, 351 insertions(+), 277 deletions(-) diff --git a/UPGRADE.md b/UPGRADE.md index 9f23bc4..57200c7 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -6,48 +6,69 @@ then decide which starter changes to adopt in each generated project. ## Upgrading from 2.0.0 to 2.1.0 -YouTube and Vimeo oEmbeds gain optional privacy mode and JavaScript-free -click-to-load posters. Both are off by default; enable them with the -`emulsify_theme_media_embed_options` filter. Existing child themes inherit the -runtime and the parent poster fallback without copying starter files. Review -your Content Security Policy before enabling click to load: its `srcdoc` -document inherits the page policy, including restrictions on inline styles. -Projects can override `templates/partials/oembed-poster.twig`. See -[media embeds](docs/media-embeds.md) for the options and override contract. - -Argumentless Twig `bem()` and `add_attributes()` now read only inherited -`attributes`, rather than interpreting unrelated Timber context variables as -attributes or BEM options. Pass intended values explicitly if a template relied -on that accidental output. Normal explicit-argument calls and the direct PHP -helper API are unchanged; the parent update supplies this fix automatically. - -New generated themes include `AGENTS.md`, a WordPress integration guide, and -the optional `.agents/skills/emulsify-wordpress-integration/` workflow skill. -Existing child themes can copy these token-free files from a fresh comparison -theme and adapt them to their project. Start assistant sessions in the child -theme directory for local skill discovery; generation does not change -site-root agent instructions. - -The minimum WordPress version is now 6.8, matching the locked Timber dependency. -Sites running WordPress 6.7 must update WordPress before updating the theme. - -New generated themes no longer ship `templates/page.twig` or a `templates/` -directory. Existing 2.x children keep their page override: it still inherits -parent fixes when it extends `@emulsify-tpl/page.twig` and calls `parent()`. -Search project CSS for the `.-page` wrapper, such as `.acme-site-page`, -before deleting that override. Projects can remove it if the wrapper is unused -and the file has no other project customizations. - -The new `site_header`, `main`, and `page_title` layout blocks are additive; the -parent's default output remains byte-identical. Existing `head`, `header`, -`content`, and `footer` blocks retain their names and boundaries. See the -[public template extension contract](docs/template-extension.md) before adding -an override, including how to preserve the skip-link target. - -New child themes use Emulsify Core `^4.5.0`. Existing generated themes do not -inherit copied starter scripts or configuration when the parent is updated. -Updating Core is optional for existing projects; to adopt 4.5.0, change the -dependency and refresh your project's lockfile with `npm install`: +Before updating the parent theme, review the conditional actions below against +the project's overrides and components. Existing generated child themes are not +rewritten: their copied scripts, configuration, templates, and documentation +remain project-owned. New generated themes receive the updated defaults. + +### Required actions + +1. **Update WordPress first if the site is on 6.7.** The parent now requires + WordPress 6.8 or newer, matching the locked Timber dependency. PHP remains + 8.3 or newer; the PHP and Timber dependency constraints do not change. +2. **Review component resolution if the project has ambiguous paths.** Project + references now follow Core's candidate order: a flat `component.twig` + precedes `component/component.twig`, and a child's grouped component wins + over a parent's direct component. Duplicate names resolve by root, direct + candidate, then breadth-first group order. Use an explicit group or template + path where a project needs one particular component. A bare path keeps every + segment; correct references that relied on a missing first segment falling + back to another group. See the + [component resolution order](docs/timber-and-twig-authoring.md#component-includes). +3. **Apply the relevant fixes to child overrides.** Unmodified parent fallbacks + receive the fixes automatically; project copies need review: + - `templates/partials/pagination.twig`: mark only `page.current` with + `aria-current="page"`; display a translated literal `…` in the existing + non-interactive span; decode Timber's display-escaped URL entities before + passing every page, previous, and next URL to `add_attributes`. + - Numbered pagination links: prefix the number with translated `Page ` in + `span.screen-reader-text`. Keep the current-page span, ellipsis, and + Previous/Next text unchanged. + - `templates/partials/menu.twig`: mark only links whose `item.current` flag + is true with `aria-current="page"`; leave ancestors unmarked. + - Base layout: print Timber's trusted `site.language_attributes` fragment + as markup so `lang` and `dir` parse correctly, while retaining + autoescaping for other values. + - Argumentless Twig `bem()` and `add_attributes()` now read only inherited + `attributes`. Pass intended values explicitly if a template relied on + unrelated Timber context variables becoming attributes or BEM options. + Explicit-argument calls and the direct PHP helper API are unchanged. +4. **Check the rendered site's accessibility and link states.** Projects own + `screen-reader-text`, visible focus styles, and pointer target sizing. The + parent adds no frontend CSS to supply them. The rendered audit disables + only axe's `target-size` rule; that exception does not establish WCAG 2.2 + SC 2.5.8 compliance. See + [project responsibilities](docs/accessibility.md#project-responsibilities). + The parent's normal pale-blue link color has been removed because it failed + contrast on white. Its unresolved hover, focus, and active declarations + have also been removed; those declarations caused color inheritance, so + removal can reveal an existing link color. Review normal, visited, and + focused links against project backgrounds. The visited-link rule and other + parent styles and presets remain. + +Render representative pages as both an administrator and an anonymous visitor, +check the editor where blocks are used, and inspect `debug.log`. Storybook +alone does not exercise Timber context or WordPress hooks. + +### Optional actions for existing generated themes + +Choose the changes below individually and preserve local customizations. A +parent update does not copy starter files into an existing child theme. + +#### Update Core and the effective Node version + +New child themes use Emulsify Core `^4.5.0`. To adopt it in an existing project, +change the dependency and refresh the project's lockfile with `npm install`: ```diff "dependencies": { @@ -56,18 +77,17 @@ dependency and refresh your project's lockfile with `npm install`: } ``` -Core requires Node.js 24.13.0 or newer, even though the generated theme advertises -`>=24` and `.nvmrc` selects only the Node 24 line. Check the resolved package's -engines after updates. Core 4.5.0 adds bounded accessibility concurrency and an -optional `@emulsify/core/a11y/wcag22` preset. Its Twig resolver preserves every -segment of bare paths: correct references that relied on a missing first -segment falling back to another group. +Use Node.js 24.13.0 or newer for Core 4.5.0. The generated theme's `>=24` engine +and `.nvmrc` value `24` do not express that effective minor-version floor; check +the resolved Core package's engines after dependency updates. Core 4.5.0 adds +bounded accessibility concurrency and an optional +`@emulsify/core/a11y/wcag22` preset. The preset is not enabled automatically. + +#### Preserve every lint and format failure -To adopt the copied tooling fixes, edit the existing project's `package.json` -and copy the updated configs described below. Parent theme updates alone do -not replace these files. Adoption is optional for an existing project, but the -lint/format failure fixes are recommended so CI cannot silently pass a failed -check. These commands still run both steps: +Adopting these copied script fixes is recommended so CI cannot silently pass a +failed first check. Update the existing project's `package.json`; both steps +still run: ```diff "scripts": { @@ -80,6 +100,8 @@ check. These commands still run both steps: } ``` +#### Adopt the Core accessibility runner + The accessibility command stays the same before and after: ```json @@ -90,18 +112,20 @@ The accessibility command stays the same before and after: } ``` -For this optional migration, update Core to `^4.5.0` first and replace the copied +Update Core to `^4.5.0` first, then replace the copied `config/emulsify-core/run-a11y.js` with the current starter's thin wrapper. Changing `package.json` alone does not replace the old runner. The new wrapper uses Core's bounded concurrency, complete reporting, and static server, while -retaining WordPress's failure when no stories are found. Core resolves project -`a11y.config.js` from the working directory and defaults the Storybook output -to `.out`; existing `pa11y.wait` settings still apply. Use a sandbox-capable -Chrome rather than disabling its sandbox. The WCAG 2.2 preset is a separate, -optional choice; it is not enabled by this migration. +retaining WordPress's failure when no stories are found. Core loads project +`config/emulsify-core/a11y.config.js` from the working directory and defaults +Storybook output to `.out`; existing `pa11y.wait` settings still apply. Preserve +project configuration and use a sandbox-capable Chrome. Never disable its +sandbox to make a check pass. Choosing the WCAG 2.2 preset remains optional. -To adopt ESM tests and project coverage, copy `config/jest.config.js` from the -current starter and update the test commands together: +#### Run ESM tests and collect project coverage + +Copy `config/jest.config.js` from a fresh comparison theme and update both test +commands together, preserving project-specific Jest settings: ```diff "scripts": { @@ -112,63 +136,72 @@ current starter and update the test commands together: } ``` -This optional copied-tooling migration intentionally makes an empty project -fail tests, matching new generated themes. Add at least one real test before -enabling it in an existing project's CI. Keep project-specific Jest settings; -the new defaults retain `rootDir: '..'` and the existing test locations, add +This optional migration makes an empty project's test command fail, matching new +generated themes. Add a real project test before enabling it in existing CI. +The defaults retain `rootDir: '..'` and the existing test locations, add `transform: {}` and V8 coverage, and exclude dependencies, built files, stories, -and test files from source coverage. - -The parent no longer emits link hover, focus, and active color declarations -that referenced an undefined custom property. Those declarations caused color -inheritance; removing them can reveal existing link colors. In the default -fixture, focused links keep their normal color instead of inheriting the -surrounding text color. Review child theme link-state styles if they relied on -that inheritance. - -The normal link color rule has also been removed from parent `theme.json` -because its pale blue failed contrast on white. Normal anchor colors now fall -through to WordPress core, browser, or project styles. The visited-link rule -and all other styles and presets are unchanged by this additional removal. -Review normal, visited, and focused links against the project's backgrounds. - -The document shell now prints Timber's trusted WordPress language-attribute -fragment as markup, allowing the browser to parse `lang` and any `dir` -attribute correctly. Child base-layout overrides should apply the same narrow -treatment to `site.language_attributes` while retaining autoescaping for other -values. - -Project component references now resolve components inside group folders and -accept both `project_machine_name:component` and -`@project_machine_name/component`. Resolution follows Emulsify Core, so a flat -`component.twig` now precedes `component/component.twig`; earlier WordPress -versions preferred the latter. Check projects that contain both forms or -duplicate component names, and use an explicit group or template path to keep -the intended choice. See the [component resolution order](docs/timber-and-twig-authoring.md#component-includes). - -Fallback pagination ellipses remain non-interactive spans with the existing BEM -classes, now displaying a translated `…` and omitting `aria-current`. Child -themes that override `templates/partials/pagination.twig` should adopt these -markup corrections and decode Timber's display-escaped URLs before passing -them to `add_attributes`. The parent adds no frontend CSS. - -Numbered pagination links now prefix their number with a translated `Page ` -inside `span.screen-reader-text`. Adopt this label in child pagination overrides; -keep the current-page span, ellipsis, and Previous/Next labels unchanged. Verify -your project loads a compatible visually hidden helper and visible focus styles. -Projects also own pointer target sizing; the parent audit's sole `target-size` -exception does not establish SC 2.5.8 compliance. See -[accessibility responsibilities](docs/accessibility.md#project-responsibilities). - -Child themes overriding `templates/partials/menu.twig` should also mark only -links whose `item.current` flag is true with `aria-current="page"`. - -The release tooling change requires no runtime or generated child theme edits. -Maintainers need a full Git checkout with tags to run release checks. Before -publishing, prepare a release PR that updates root `package.json` and all derived -version metadata together, then confirm semantic-release computes the same -version. A checkout still at the latest tagged version skips publishing. See -the [release PR procedure](docs/release-process.md#release-pr-procedure). +and test files from source coverage. Coverage includes eligible project files +that tests do not import. Projects using JSX or TypeScript need their own +transformer that emits ESM. + +#### Keep audit JSON machine-readable + +The copied `audit` and `audit:twig-stories` wrappers now send their documentation +footers to stderr and preserve the underlying audit's exit status. Copy those +two script values from a fresh comparison theme if adopting the fix. If project +automation parses audit JSON, adopt it before relying on stdout as JSON, and +capture diagnostics separately. See the [project audit](#project-audit). + +#### Simplify template overrides + +New generated themes no longer ship `templates/page.twig` or a `templates/` +directory. Existing 2.x children can keep their override: it still inherits +parent fixes when it extends `@emulsify-tpl/page.twig` and calls `parent()`. +Search project CSS for the `.-page` wrapper, such as `.acme-site-page`, +before deleting the file. Remove it only if the wrapper is unused and the file +has no other project customizations. + +The new `site_header`, `main`, and `page_title` layout blocks are additive and +do not change the default output. Existing `head`, `header`, `content`, and +`footer` names and boundaries remain. Use the +[public template extension contract](docs/template-extension.md) when adding +an override, including preserving the `#content` skip-link target. A same-path +child override extends `@emulsify-tpl/...`; extending `@templates/...` resolves +back to itself. + +#### Enable privacy-friendly video embeds + +YouTube and Vimeo oEmbeds gain optional privacy mode and JavaScript-free +click-to-load posters. Both are off by default. Existing child themes inherit +the runtime and parent poster fallback without copying starter files. Enable +them with `emulsify_theme_media_embed_options` only after reviewing the site's +Content Security Policy. The `srcdoc` document inherits that policy, including +inline-style restrictions; the poster's CSS is confined to its iframe. + +Provider posters still request a third-party thumbnail before activation; +choose `poster: 'neutral'` to avoid that request. The provider receives data once +the player loads, and Back returns to the poster. A child can override +`templates/partials/oembed-poster.twig`. See [media embeds](docs/media-embeds.md) +for the options, override contract, metadata cache, and CSP requirements. + +#### Adopt project-local AI guidance + +New generated themes include `AGENTS.md`, `docs/wordpress-integration.md`, and +`.agents/skills/emulsify-wordpress-integration/SKILL.md`. Existing projects can +copy these token-free files from a fresh comparison theme and adapt them. Keep +the hidden `.agents/` directory when copying, and resolve the guide's links +within the project. Start assistant sessions in the child theme directory for +skill discovery. Generation does not change site-root agent instructions. + +### Release maintainers + +The release-tooling changes need no site runtime or generated-theme edits. +Maintainers need a full Git checkout with stable tags to run release checks. +Prepare root `package.json` and every derived version together in a release PR, +then confirm semantic-release computes that exact version. A checkout still at +the latest tagged version skips publishing. Follow the +[release PR procedure](docs/release-process.md#release-pr-procedure), including +re-syncing the standalone starter before release. ## Upgrading from 1.x to 2.0.0 diff --git a/docs/README.md b/docs/README.md index 61fb092..23a2baf 100644 --- a/docs/README.md +++ b/docs/README.md @@ -3,6 +3,7 @@ ## Getting started - [Generated-theme upgrade guide](../UPGRADE.md) +- [Required and optional 2.0.0 to 2.1.0 upgrade actions](../UPGRADE.md#upgrading-from-200-to-210) - [Upgrading from 1.x to 2.x](upgrading-1x-to-2x.md) - [Parent and child theme architecture](parent-child-architecture.md) - [WP-CLI child theme generation](wp-cli-child-theme-generation.md) @@ -33,8 +34,11 @@ - [Asset loading](asset-loading.md) - [Release process](release-process.md) -- [Next release notes draft](release-notes-next.md) +- [2.1.0 release notes (unreleased)](release-notes-next.md) +- [2.1.0 release PR checklist](release-notes-next.md#release-pr-checklist) - [Sister-project parity contract](sister-project-parity.md) +- [Drupal 7.3.0 sync log](sister-project-parity.md#sync-log) +- [How to run a sister-project sync](sister-project-parity.md#how-to-run-a-sync) - [Post-2.x optimization roadmap](post-2x-optimization-roadmap.md) ## Continuous integration diff --git a/docs/release-notes-next.md b/docs/release-notes-next.md index 5f27bff..8394175 100644 --- a/docs/release-notes-next.md +++ b/docs/release-notes-next.md @@ -1,175 +1,151 @@ # 2.1.0 Release Notes Draft -Status date: 2026-10-23 +Status date: 2026-10-23. Unreleased; the parity work and release preparation +must merge before publication. These notes describe changes since `2.0.0`, +including the [Drupal 7.3.0 sync](sister-project-parity.md#sync-log). ## Version strategy Latest published tag: `2.0.0`. This draft tracks the upcoming `2.1.0` release. -Root `package.json` remains at the latest stable version until a release PR -bumps it and every derived version surface together. The version contract -accepts the latest stable tag or an untagged next patch, minor, or major, and -requires full Git history and tags. It rejects already-tagged draft versions -and stale claims about the latest release. +Root `package.json` remains at `2.0.0` until a release PR prepares `2.1.0` +together with every derived version surface. The version contract accepts the +latest stable tag or an untagged next patch, minor, or major, and requires full +Git history and tags. It rejects already-tagged draft versions and stale claims +about the latest release. -The guarded runner skips publishing when no new version is prepared. Once a -release PR prepares `2.1.0`, semantic-release must compute exactly `2.1.0` from -Conventional Commits before it can publish the committed theme headers, ZIP, -and generated-theme lineage. See the [release process](release-process.md). +Publishing skips successfully when no version is prepared. Once `2.1.0` is +prepared, semantic-release must compute exactly `2.1.0` from Conventional +Commits before publishing the committed theme headers, ZIP, and generated-theme +lineage. See the [release process](release-process.md). -## Compatibility notes - -- WordPress 6.8 or newer; PHP 8.3 or newer. -- Timber `^2.3` is required by the parent theme. -- Generated child themes use Emulsify Core `^4.5.0` and the Vite build workflow. -- Generated child themes keep `Template: emulsify`; `whisk` remains a - generation-only starter source and must not be activated directly. -- Root release tooling requires Node.js `>=24.10`; generated child theme tooling - advertises Node.js `>=24`, but Core requires `>=24.13.0`, the effective floor. - -## Draft GitHub release notes - -Title: `2.1.0` - -### Added +## Added - Optional privacy mode and JavaScript-free click-to-load posters for YouTube - and Vimeo oEmbeds, configured through `emulsify_theme_media_embed_options`. - Both features are off by default. Neutral posters avoid provider image - requests before activation; child themes can override the accessible - `srcdoc` document through the normal template namespace. -- A rendered WordPress accessibility audit covers seven real fixture routes, - including YouTube and Vimeo click-to-load posters, with - explicit WCAG 2.2 AA tags, menu/pagination assertions, and per-route HTML, - axe JSON, and screen-reader-text evidence. Its sole `target-size` exception - preserves the parent's no-frontend-CSS boundary; projects own final styling. -- Generated child themes include portable `AGENTS.md` guidance, a WordPress - integration guide, and an optional integration workflow skill. Both - generators preserve these files unchanged, including hidden `.agents/` - content. Root contributor guidance covers maintainer ownership and checks. -- Public `site_header`, `main`, and `page_title` layout extension blocks, with - byte-for-byte golden coverage and documented boundaries. Existing layout - blocks and default parent output remain unchanged. -- Timber-first WordPress parent theme with Twig route fallbacks, global context, - namespace registration, and Emulsify-compatible switch tags. -- `wp emulsify` WP-CLI child theme generation with `--machine-name`, - `--description`, `--parent`, `--dry-run`, `--force`, and `--activate`. -- Standalone starter generation through + and Vimeo oEmbeds. Both default to off. The + `emulsify_theme_media_embed_options` filter controls privacy, activation, and + provider or neutral posters; children can override the accessible `srcdoc` + document. See [media embeds](media-embeds.md) for CSP and privacy requirements. +- Public `site_header`, `main`, and `page_title` Twig blocks let children extend + the parent layout without copying it. Existing block names, boundaries, and + default output are preserved and tested. See + [template extension](template-extension.md). +- Generated themes include portable `AGENTS.md` guidance, a WordPress + integration guide, and an optional integration skill. Both generators retain + identical guidance, including the hidden `.agents/` directory. The root + contributor guide documents maintainer ownership and validation. +- Rendered WordPress accessibility checks cover seven fixture routes, including + paginated content and video posters. They retain HTML and axe reports, check + current menu/pager items, and use five WCAG tags through WCAG 2.2 AA. Only + `target-size` is disabled because projects style the fallbacks; see + [accessibility](accessibility.md). +- A shared fixture characterizes PHP Twig helpers against installed Emulsify + Core JavaScript helpers, recording known differences explicitly. Run + `npm run test:twig-helpers`; see + [Twig authoring](timber-and-twig-authoring.md). + +## Changed + +- New generated themes use Emulsify Core `^4.5.0`, with an effective Node.js + minimum of 24.13.0. Existing child manifests and lockfiles remain + project-owned. +- New generated themes ship no Twig overrides or `templates/` directory. Pages + use parent fallbacks; projects create overrides only when needed. +- Project tests execute native ESM, collect project coverage including untested + source, and fail when no tests exist. Lint, lint-fix, and format run all steps + while preserving any failure. The accessibility wrapper uses Core's bounded + runner, preserves the empty-audit failure, honors `pa11y.wait`, and keeps + Chrome's sandbox enabled. +- CI pins all actions to commit SHAs, updates them through Dependabot, and + protects the blocking WordPress/PHP matrix with a contract check. Compatible + release-tooling lockfile updates address independently resolved dependency + findings; remaining development findings and upstream owners are recorded in + the [October 23 audit](dependency-audit-2026-10-23.md). + +## Fixed + +- Pagination marks only the actual current page, renders a translated ellipsis + character as non-interactive text, and preserves query arguments in numbered, + previous, and next links. Numbered links now include a translated `Page ` + prefix in a `screen-reader-text` span, with no added frontend CSS. +- Fallback menus mark the current item with `aria-current="page"`, leaving + ancestors unmarked. +- Project component references follow Core's direct candidates and breadth-first + grouped discovery, including `.html.twig` and the + `@project_machine_name/component` alias. Each child root is searched completely + before parent roots, with deterministic duplicates and root containment. +- Twig helpers register Timber's safety/context options correctly. Argumentless + `bem()` and `add_attributes()` calls no longer consume unrelated context as + arguments or expose it as attributes. +- The document shell preserves trusted WordPress language attributes as markup, + producing a valid `lang` attribute. +- Parent `theme.json` no longer sets the failing pale-blue normal link color or + invalid hover, focus, and active color declarations. Other styles and editor + presets remain; review project link presentation after updating. +- Generated audit wrappers keep JSON stdout clean by sending documentation + footers to stderr, while preserving arguments and Core's exit status. +- Release tooling accepts prepared versions after `2.0.0`, rejects stale draft + versions and release claims, and prevents publishing mismatched metadata. + `npm run release:check -- --skip-smoke` now honors its documented behavior. + +## Compatibility + +- **WordPress 6.8 or newer is required**, raised from 6.7 to match locked Timber + 2.5.1. Update WordPress before updating the theme. PHP remains 8.3 or newer; + the PHP and Timber package constraints are unchanged. +- The minimum WordPress version and current stable release are blocking CI + legs. `Tested up to` is 7.1 after a passing fixture on 7.1.3; nightly remains + advisory. Practical checks block on PHP 8.3, 8.4, and 8.5. +- Generated child tooling advertises Node.js `>=24`, while Core sets the + effective minimum at 24.13.0. Root maintainer tooling requires `>=24.10`. +- Existing children keep `Template: emulsify` and their copied files. Parent + updates do not replace their scripts, Jest config, guidance, or dependencies. + Existing parent-extending page overrides continue receiving parent fixes. + +## Consumer action + +Before updating, sites on WordPress 6.7 must upgrade WordPress to at least 6.8. +After updating, check menus, long archives, filtered searches, link colors, and +language markup. Projects own target size, `screen-reader-text`, and focus +styles; confirm the new pager prefix is visually hidden by the site's styles. + +Adopting copied tooling fixes, Core `^4.5.0`, AI guidance, or the new extension +blocks in an existing child is optional. Copy the relevant changes into the +child deliberately; refreshing the parent does not change those files. An old +page wrapper can remain, or be removed after checking project CSS. Privacy and +click-to-load embeds remain off until enabled; review CSP and playback behavior +before opting in. See the required and optional actions in +[UPGRADE.md](../UPGRADE.md#upgrading-from-200-to-210). + +## Release PR checklist + +- [ ] Merge the parity implementation PRs and review the + [sync log](sister-project-parity.md#sync-log) against the final changes. +- [ ] Prepare `2.1.0` following the [release procedure](release-process.md): bump + root `package.json`, both theme `Version` headers, `whisk/package.json`, and + `whisk/project.emulsify.json` lineage together, including applicable lockfile + metadata. This documentation change does not bump versions. +- [ ] Re-sync [`emulsify-wordpress-starter`](https://github.com/emulsify-ds/emulsify-wordpress-starter) - and its `.cli/init.js` hook. -- Project-specific documentation in generated child themes: `README.md`, - `docs/development.md`, `docs/upgrading.md`, and `docs/support-information.md`, - resolved from `%%EMULSIFY_*%%` tokens at generation time. -- A generated child theme contract with focused tests, plus a cross-path parity - smoke that requires both generation paths to produce the same file tree. -- ACF/Twig block registration, native `block.json` registration, block pattern - registration, editor policy controls, and editor enhancements. -- Asset discovery and enqueuing for built child theme output under `dist/`. - -### Changed - -- Fresh generated themes ship no Twig overrides or `templates/` directory. - Pages render through parent fallbacks. Existing child page overrides keep - inheriting parent fixes; search project CSS for the machine-name wrapper - before deciding whether to remove one. -- New generated themes use Emulsify Core `^4.5.0`; their effective Node.js floor - is 24.13.0. Existing projects should refresh their own dependency lockfiles - when adopting this optional Core update. -- Root `package.json` is now the single source of truth for the release version. - Theme headers, starter metadata, generated lineage, and smoke assertions all - derive from it instead of restating a literal. -- Both generation paths emit two-space JSON so generated metadata is identical - regardless of which path a project used. -- Generation-only tooling is no longer left in generated child themes. The - WP-CLI generator excludes `.cli`, and the standalone starter hook removes - itself after a successful run. -- `docs-command-check.cjs` now exports a reusable validator and can validate a - real generated child theme directory, not just repository documentation. - -### Compatibility - -- The minimum WordPress version is now 6.8, matching the locked Timber 2.5.1 - requirement. Sites on WordPress 6.7 must update WordPress before updating the - theme. The minimum fixture remains blocking; the current stable release is - also blocking and `Tested up to` is 7.1 after validation on 7.1.3. Nightly is - advisory. Practical checks now block on PHP 8.3, 8.4, and 8.5. - -### Fixed - -- Argumentless Twig `bem()` and `add_attributes()` calls no longer mistake the - whole Timber context for helper arguments. Unrelated variables cannot leak - into attributes or supply unintended BEM classes. Explicit-argument behavior - and the direct PHP API remain unchanged. -- The document shell renders Timber's trusted WordPress language attributes as - markup so `lang` is valid. The failing pale-blue normal link color is removed - from parent `theme.json`; normal colors fall through to core, browser, or - project styles, while visited-link styling and all other styles and presets - are retained. -- Numbered fallback pagination links include a translated `Page ` label in a - `screen-reader-text` span. Current/ellipsis spans and Previous/Next links keep - their existing labels and classes; no frontend CSS is added. -- Copied lint, lint-fix, and format scripts run every step and propagate any - failure. Generated audit wrappers send documentation footers to stderr while - preserving JSON stdout, arguments, and Core's exit status. -- Project Jest commands execute native ESM, collect project coverage including - untested source, and fail when no tests exist. Existing generated themes must - copy the updated scripts and Jest configuration to adopt this behavior. -- The accessibility wrapper delegates to Core's bounded runner and complete - reporting, keeps the empty-audit failure, honors project `pa11y.wait`, and - never disables Chrome's sandbox. -- Removed link hover, focus, and active color rules that referenced an undefined - custom property instead of introducing replacement link-state colors. This - can reveal existing link colors where the invalid declarations previously - caused inheritance; see [upgrade guidance](../UPGRADE.md#upgrading-from-200-to-210). -- Project component references now match Emulsify Core's direct candidates and - breadth-first grouped discovery, including `.html.twig` templates and the - `@project_machine_name/component` alias. Each child root is searched before - parent roots, with deterministic duplicate precedence and root containment. -- Fallback menus set `aria-current="page"` only on the current menu item, - leaving its ancestors unmarked. -- Fallback pagination marks only the actual current page, renders a translated - ellipsis character as non-interactive text, and preserves multi-argument URLs - in numbered, previous, and next links without double escaping. -- Twig helper registration uses Timber's function options so rendered attribute - markup remains intact and helpers receive the intended Twig context. -- Release tooling supports prepared releases after `2.0.0`, validates versions - against stable Git tags, and prevents publishing metadata that differs from - the version semantic-release computes. `release:check -- --skip-smoke` now - skips runtime smoke commands as documented. -- `style.css` header replacement no longer warns when a header already holds the - requested value; only a genuinely missing header is reported. - -### Internal - -- Workflow actions use full SHA pins and Node 24 artifact uploads, maintained - by weekly GitHub Actions Dependabot updates. Validation credentials are - read-only; the publish dry run uses the publishing job's scoped token. - Project-board and contributor automation use pinned releases and corrected - token configuration. -- Compatible root lockfile updates fix the independently resolved Handlebars, - JS-YAML, Fast URI, and Undici findings. Remaining development-only findings - belong to semantic-release's bundled npm; see the - [October 23 audit](dependency-audit-2026-10-23.md). -- Added `npm run test:twig-helpers` to characterize PHP and installed Core - JavaScript helpers with exact HTML, attribute maps, and remaining context. - Separate expectations preserve observed differences without changing runtime - behavior or claiming universal parity. -- Re-sync the standalone `emulsify-wordpress-starter` repository from `whisk/` - before publishing, including its hidden `.agents/` directory and AI guidance. -- Extended CI verifies the runner's installed Chrome with its sandbox enabled, - runs real seeded project tests before accessibility checks, and retains - coverage diagnostics. -- Added `npm run test:generated-theme` and `npm run smoke:generation-parity`. -- Added release checks for generated documentation sections, the documentation - token list across both generators, and the generated child theme contract. + from `whisk/`, including hidden `.agents/` guidance and the standalone + `.cli/init.js` generator; confirm both generation paths still agree. +- [x] Confirm a published `@emulsify/core` version satisfies `^4.5.0`: + [4.5.0](https://www.npmjs.com/package/@emulsify/core/v/4.5.0) was verified in + the npm registry on 2026-10-23 and requires Node.js 24.13.0 or newer. +- [ ] Confirm the version resolved by a fresh generated theme lockfile still + satisfies that range and the effective Node requirement. +- [ ] Run the readiness checks below on the prepared committed files, push the + release PR, and confirm semantic-release computes exactly `2.1.0` before + merging. Rotate this draft after publication. ## Validation notes -- Run `npm run release:check -- --skip-smoke` for static validation. -- Run `npm run test:generated-theme` and `npm run smoke:generation-parity` for - generation coverage. +- Run `npm run docs:check-commands` and `npm run lint:php`. +- Run `npm run pr:check`, `npm run test:generated-theme`, and + `npm run smoke:generation-parity`. - Run `npm run release:check` with WP-CLI and MySQL available for the full - WordPress fixture path. -- Run `npm run publish-test -- --no-ci` before publishing. -- Publish the compatible `@emulsify/core` release before Emulsify WordPress - `2.1.0`. + WordPress fixture path; set `WP_SMOKE_REQUIRED=1` so missing prerequisites fail. +- Run `npm run release:check -- --skip-smoke` only for static validation; it + does not replace the full release gate. +- After pushing the release PR, run `npm run publish-test -- --no-ci` to verify + the prepared version matches semantic-release's calculation. diff --git a/docs/sister-project-parity.md b/docs/sister-project-parity.md index 846dfd0..5bf6ebd 100644 --- a/docs/sister-project-parity.md +++ b/docs/sister-project-parity.md @@ -81,6 +81,64 @@ Emulsify CLI uses the standalone `https://github.com/emulsify-ds/emulsify-wordpr This file may also hold Emulsify Core metadata such as `variant.structureImplementations`. `theme.json` remains the WordPress site and editor configuration surface, not a component-library or Twig namespace registry. +## Sync log + +| Sister reference | WordPress target | Status | +| --- | --- | --- | +| Drupal 7.3.0: [7.2.2](https://github.com/emulsify-ds/emulsify-drupal/tree/7.2.2) plus [PR #382](https://github.com/emulsify-ds/emulsify-drupal/pull/382), pinned at [`f5d7d4a`](https://github.com/emulsify-ds/emulsify-drupal/commit/f5d7d4ac572fc583c734213879a15dde2140f6c3) | WordPress 2.1.0 | Implemented in [PR #47](https://github.com/emulsify-ds/emulsify-wordpress/pull/47); pending merge and release. | + +This sync includes the 7.2.2 fixes underlying the Drupal 7.3.0 reference. The +WordPress work is collected in one release branch by maintainer decision. +[PR #42](https://github.com/emulsify-ds/emulsify-wordpress/pull/42)'s audit-wrapper +change is incorporated into PR #47; its separate PR is still open. These are +implementation references, not a claim that either release has been published. + +### Ported or adapted + +| Area | Classification and reason | Implementation | +| --- | --- | --- | +| Release-version contracts and stale release claims | Adapt: WordPress publishes prepared, committed version metadata, and semantic-release must calculate the same version. | [PR #47](https://github.com/emulsify-ds/emulsify-wordpress/pull/47); [release procedure](release-process.md). | +| Core 4.5, lint/format exit status, ESM tests and coverage, audit JSON, and sandboxed accessibility runner | Port/adapt: shared frontend tooling retains WordPress's failures for empty test suites and empty accessibility audits. | [PR #47](https://github.com/emulsify-ds/emulsify-wordpress/pull/47), including [audit-wrapper PR #42](https://github.com/emulsify-ds/emulsify-wordpress/pull/42). | +| Pager/menu current items, hidden Page prefix, and rendered accessibility audit | Adapt: Timber fallbacks and seven WordPress fixture routes replace Drupal render templates and routes. | [PR #47](https://github.com/emulsify-ds/emulsify-wordpress/pull/47); [accessibility contract](accessibility.md). | +| Public Twig extension blocks and removal of the starter page override | Adapt: additive parent blocks preserve WordPress template inheritance and existing child overrides. | [PR #47](https://github.com/emulsify-ds/emulsify-wordpress/pull/47); [template extension contract](template-extension.md). | +| Generated AI guidance and root contributor guidance | Adapt: library-neutral instructions describe Timber, WordPress hooks, blocks, and verification. | [PR #47](https://github.com/emulsify-ds/emulsify-wordpress/pull/47); [generated-theme contract](generated-child-theme-contract.md). | +| PHP/JavaScript Twig helper characterization | Port/adapt: a shared corpus records intentional helper differences and catches runtime bugs without silently changing contracts. | [PR #47](https://github.com/emulsify-ds/emulsify-wordpress/pull/47); [Twig authoring](timber-and-twig-authoring.md). | +| Action pins, blocking readiness matrix, dependency maintenance, and README assets | Port/adapt: shared validation policy uses WordPress versions and the supported PHP matrix. | [PR #47](https://github.com/emulsify-ds/emulsify-wordpress/pull/47); [dependency audit](dependency-audit-2026-10-23.md). | +| Privacy video embeds | Adapt, implemented for 2.1.0: render-time oEmbed filters, overridable iframe `srcdoc`, and cached background Vimeo metadata replace Drupal's same-origin embed document. Both options default to off. | [PR #47](https://github.com/emulsify-ds/emulsify-wordpress/pull/47); [media embeds](media-embeds.md). | + +WordPress companion fixes in the same PR align grouped component discovery with +Core, raise the minimum WordPress version to 6.8 to match locked Timber, and +correct fallback language markup and failing link-color declarations. See the +[2.1.0 notes](release-notes-next.md) and [upgrade actions](../UPGRADE.md#upgrading-from-200-to-210). + +### Intentional differences + +| Sister feature | WordPress decision and reason | +| --- | --- | +| 24 px target-size rule and Drupal's parent asset build | Skip: the parent adds no frontend CSS to the surrounding page; projects own target size, `screen-reader-text`, and focus styles. Admin-only CSS is allowed. | +| Regions: `status`, `breadcrumb`, `highlighted`, `sidebar_first`, `sidebar_second` | Skip: WordPress uses menus, block patterns, and template parts; the parent registers no widget areas. Maintainers may revisit this choice. | +| Container and form template suggestions, inline form-error IDs, status-message/feed/progress template moves, and select option attributes | Skip: these belong to Drupal's render and Form APIs. | +| Favicon dark-mode icons, SVG sanitization, and generated social share image | Skip: WordPress uses core Site Icon and `custom-logo`; SEO plugins own `og:image`. | +| Default parent logo, library/breakpoint contract, Drupal.org channel-skew validation, Stable 9 parity, and the `dr` CLI | Skip: these are Drupal-only runtime and distribution contracts. | + +The opt-in video poster is a separate iframe document with its own inline +accessibility styles, as approved for that feature. It adds no stylesheet to +the surrounding page; its CSP and override contract are documented in +[media embeds](media-embeds.md#poster-behavior-and-overrides). + +## How to run a sync + +- [ ] Read the sister release's notes and upgrade guide from a full clone with + history and tags; record the exact tag and PR head used as the reference. +- [ ] Classify every change as port, adapt, or skip, with a reason grounded in + the shared project model and native WordPress behavior. +- [ ] Open one PR per area, link the source changes, and keep both child-theme + generators and their contracts aligned where applicable. +- [ ] Run the relevant readiness checks, including the full WordPress fixture + for rendering or generation changes, and document consumer actions. +- [ ] Update this sync log, release notes, and upgrade guide with implementation + links, intentional differences, and the actual merge/release status. + ## Parity guardrails Use this contract when evaluating future changes: @@ -90,4 +148,7 @@ Use this contract when evaluating future changes: - Keep Whisk small enough to work as a starter. - Keep generated child theme source aligned with Emulsify Core 4 conventions. - Keep WordPress-specific behavior explicit rather than hiding it behind Drupal naming or assumptions. +- The parent adds no frontend CSS to the surrounding page. Admin-only CSS is + allowed; projects own target size, `screen-reader-text`, and focus styles. + Opt-in video posters confine their accessibility styles to the iframe document. - Keep `project.emulsify.json` on `"platform": "wordpress"` and `generatedFrom: "emulsify-wordpress"` so generated child themes advertise the WordPress platform adapter and starter lineage. From 99c31e27f1a4b5ec82159a77e1bf8f96382a9d5f Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Fri, 23 Oct 2026 22:52:08 -0500 Subject: [PATCH 034/100] fix(media): accept missing post IDs in the embed filter --- .github/scripts/media-embed-smoke.php | 77 +++++++++++++++++++++++++-- docs/media-embeds.md | 6 +++ includes/Media/Embeds.php | 27 ++++++---- 3 files changed, 97 insertions(+), 13 deletions(-) diff --git a/.github/scripts/media-embed-smoke.php b/.github/scripts/media-embed-smoke.php index 49f683b..d2b5b7e 100644 --- a/.github/scripts/media-embed-smoke.php +++ b/.github/scripts/media-embed-smoke.php @@ -69,7 +69,9 @@ function emulsify_media_smoke_markup( string $source, string $title = 'Fixture v } $options = array(); -$options_hook = static function ( array $defaults ) use ( &$options ): array { +$embed_context = null; +$options_hook = static function ( array $defaults, $url, $attr, $post_id ) use ( &$options, &$embed_context ): array { + $embed_context = array( $url, $attr, $post_id ); return array_merge( $defaults, $options ); }; $translation = static function ( string $translation, string $text, string $domain ): string { @@ -97,13 +99,15 @@ function emulsify_media_smoke_markup( string $source, string $title = 'Fixture v $created_posts = array(); $original_post = $GLOBALS['post'] ?? null; $original_embed_post = $GLOBALS['wp_embed']->post_ID; +$original_embed_url = $GLOBALS['wp_embed']->last_url; +$original_embed_attr = $GLOBALS['wp_embed']->last_attr; $child_dir = get_stylesheet_directory(); $override = $child_dir . '/templates/partials/oembed-poster.twig'; $original_file = is_file( $override ) ? file_get_contents( $override ) : null; $created_dirs = array(); $hostile_title = 'Fixture & "title"'; -add_filter( 'emulsify_theme_media_embed_options', $options_hook, 999 ); +add_filter( 'emulsify_theme_media_embed_options', $options_hook, 999, 4 ); add_filter( 'gettext', $translation, 999, 3 ); add_filter( 'pre_http_request', $block_http, 999 ); @@ -112,6 +116,47 @@ function emulsify_media_smoke_markup( string $source, string $title = 'Fixture v foreach ( $fixtures as $name => $fixture ) { $html = " \n" . emulsify_media_smoke_markup( $fixture['source'] ) . "\n "; emulsify_media_smoke_assert( apply_filters( 'embed_oembed_html', $html, $fixture['url'], array(), 0 ) === $html, 'Disabled options must preserve the exact ' . $name . ' bytes.' ); + foreach ( array( null, false ) as $missing_post_id ) { + $options = array(); + emulsify_media_smoke_assert( apply_filters( 'embed_oembed_html', $html, $fixture['url'], array(), $missing_post_id ) === $html, 'Missing post IDs must preserve disabled ' . $name . ' embeds byte for byte.' ); + emulsify_media_smoke_assert( array( $fixture['url'], array(), null ) === $embed_context, 'The options filter must receive a null post ID outside a post.' ); + $options = array( 'privacy_mode' => true, 'click_to_load' => true, 'poster' => 'neutral' ); + $output = apply_filters( 'embed_oembed_html', $html, $fixture['url'], array(), $missing_post_id ); + $srcdoc = emulsify_media_smoke_attribute( $output, 'srcdoc' ); + emulsify_media_smoke_assert( is_string( $srcdoc ) && '' !== $srcdoc, 'Missing post IDs must allow ' . $name . ' poster rendering.' ); + $document = emulsify_media_smoke_document( $srcdoc ); + $player = $document->getElementsByTagName( 'a' )->item( 0 )->getAttribute( 'href' ); + emulsify_media_smoke_assert( str_contains( $player, 'autoplay=1' ) && str_contains( $player, 'youtube' === $name ? 'www.youtube-nocookie.com' : 'dnt=1' ), 'Missing post IDs must retain privacy and activation parameters.' ); + } + $options = array(); + } + + // The public filter can be applied by code other than WP_Embed. Preserve + // non-string results and normalize optional context before exposing it. + // Core's earlier callbacks require strings, so test our pass-through directly. + foreach ( array( null, false, 42, array( 'provider' => 'unavailable' ), (object) array( 'provider' => 'unavailable' ) ) as $value ) { + emulsify_media_smoke_assert( $value === $service->filter_html( $value, null, false, null ), 'Non-string embed results must pass through unchanged.' ); + } + foreach ( array( + array( 42, 42 ), + array( '42', 42 ), + array( '0042', 42 ), + array( PHP_INT_MAX, PHP_INT_MAX ), + array( (string) PHP_INT_MAX . '0', null ), + array( 'invalid', null ), + array( '-1', null ), + array( '1.5', null ), + array( '', null ), + array( -1, null ), + array( 0, null ), + ) as list( $post_id, $expected_id ) ) { + $html = emulsify_media_smoke_markup( $fixtures['youtube']['source'] ); + emulsify_media_smoke_assert( $html === apply_filters( 'embed_oembed_html', $html, null, false, $post_id ), 'Invalid optional context must not change disabled embeds.' ); + emulsify_media_smoke_assert( array( '', array(), $expected_id ) === $embed_context, 'The options filter must receive normalized URLs, attributes, and positive post IDs.' ); + } + foreach ( array( true, 1.0, 1.5, array(), (object) array() ) as $post_id ) { + apply_filters( 'embed_oembed_html', $html, '', array(), $post_id ); + emulsify_media_smoke_assert( null === $embed_context[2], 'Non-integer post IDs must normalize to null.' ); } // Cached data comes from WordPress's oEmbed parse pipeline, including the @@ -267,6 +312,30 @@ function emulsify_media_smoke_markup( string $source, string $title = 'Fixture v emulsify_media_smoke_assert( null === emulsify_media_smoke_attribute( $disabled, 'srcdoc' ) && $fixture['source'] === emulsify_media_smoke_attribute( $disabled, 'src' ), 'Disabling options must immediately restore the real ' . $type . ' cached player without a cache flush.' ); } } + // WP_Embed prefers its post_ID property over the global post. Clear both to + // exercise the real no-post shortcode path, with local provider responses. + $post_before_no_post = $GLOBALS['post']; + $embed_post_before_no_post = $GLOBALS['wp_embed']->post_ID; + try { + $GLOBALS['post'] = null; + $GLOBALS['wp_embed']->post_ID = null; + foreach ( $fixtures as $name => $fixture ) { + foreach ( array( false, true ) as $enabled ) { + $options = $enabled ? array( 'privacy_mode' => true, 'click_to_load' => true, 'poster' => 'neutral' ) : array(); + $rendered = $GLOBALS['wp_embed']->shortcode( array(), $fixture['url'] ); + emulsify_media_smoke_assert( is_string( $rendered ) && '' !== $rendered, 'A no-post shortcode must return ' . $name . ' embed markup.' ); + emulsify_media_smoke_assert( null === $embed_context[2], 'The no-post shortcode must pass a null ID to the options filter.' ); + if ( $enabled ) { + emulsify_media_smoke_assert( is_string( emulsify_media_smoke_attribute( $rendered, 'srcdoc' ) ), 'A no-post shortcode must render the enabled poster.' ); + } else { + emulsify_media_smoke_assert( $fixture['source'] === emulsify_media_smoke_attribute( $rendered, 'src' ), 'A no-post shortcode must retain the disabled player.' ); + } + } + } + } finally { + $GLOBALS['post'] = $post_before_no_post; + $GLOBALS['wp_embed']->post_ID = $embed_post_before_no_post; + } remove_filter( 'pre_oembed_result', $provider_result, 999 ); // A project can override this document through the same child-first Twig @@ -302,7 +371,7 @@ function emulsify_media_smoke_markup( string $source, string $title = 'Fixture v } emulsify_media_smoke_assert( $empty_rejected, 'An empty project poster must fail closed instead of silently loading a third-party player.' ); - echo "PASS media embeds: off defaults, raw parameter preservation, privacy, translated srcdoc posters, safe URLs, asynchronous metadata, classic/block filters, child overrides, and fail-closed poster errors.\n"; + echo "PASS media embeds: missing post IDs and no-post shortcodes, normalized public filter context, off defaults, raw parameter preservation, privacy, translated srcdoc posters, safe URLs, asynchronous metadata, classic/block filters, child overrides, and fail-closed poster errors.\n"; } finally { remove_filter( 'emulsify_theme_media_embed_options', $options_hook, 999 ); remove_filter( 'gettext', $translation, 999 ); @@ -312,6 +381,8 @@ function emulsify_media_smoke_markup( string $source, string $title = 'Fixture v } $GLOBALS['post'] = $original_post; $GLOBALS['wp_embed']->post_ID = $original_embed_post; + $GLOBALS['wp_embed']->last_url = $original_embed_url; + $GLOBALS['wp_embed']->last_attr = $original_embed_attr; foreach ( $created_posts as $post_id ) { wp_delete_post( $post_id, true ); } diff --git a/docs/media-embeds.md b/docs/media-embeds.md index d0ed9b3..3e5ee02 100644 --- a/docs/media-embeds.md +++ b/docs/media-embeds.md @@ -25,6 +25,12 @@ add_filter( ); ``` +The filter also receives the original `$url`, embed attributes `$attr`, and +`$post_id`; register the callback with four accepted arguments to use them. +The post ID is a positive `int`, or `null` outside a post (for example, an embed +rendered outside the loop). Non-string URLs normalize to `''`, and non-array +attributes normalize to `array()`. + | Option | Default | Behavior | | --- | --- | --- | | `privacy_mode` | `false` | Uses YouTube's `www.youtube-nocookie.com` player host and adds Vimeo's `dnt=1`. Other parameters remain intact, including Vimeo's `h` for unlisted videos. | diff --git a/includes/Media/Embeds.php b/includes/Media/Embeds.php index 68a0b19..957fbd9 100644 --- a/includes/Media/Embeds.php +++ b/includes/Media/Embeds.php @@ -29,15 +29,22 @@ public function register(): void { /** * Changes only supported, complete player iframes. * - * @param string $html Cached provider HTML. - * @param string $url Original embed URL. - * @param array $attr Embed attributes. - * @param int $post_id Post ID. - * @return string Rendered embed HTML. + * @param mixed $html Cached provider HTML; non-strings pass through unchanged. + * @param mixed $url Original embed URL, normalized to a string. + * @param mixed $attr Embed attributes, normalized to an array. + * @param mixed $post_id Positive post ID or numeric string, otherwise null. + * @return mixed Rendered embed HTML, or the unchanged non-string input. * @throws \RuntimeException When click-to-load rendering requires missing Timber. * @throws \UnexpectedValueException When the poster template renders no document. */ - public function filter_html( string $html, string $url = '', array $attr = array(), int $post_id = 0 ): string { + public function filter_html( $html, $url = '', $attr = array(), $post_id = 0 ) { + if ( ! is_string( $html ) ) { + return $html; + } + $url = is_string( $url ) ? $url : ''; + $attr = is_array( $attr ) ? $attr : array(); + $post_id = is_string( $post_id ) ? filter_var( ctype_digit( $post_id ) ? ltrim( $post_id, '0' ) : $post_id, FILTER_VALIDATE_INT ) : $post_id; + $post_id = is_int( $post_id ) && $post_id > 0 ? $post_id : null; $options = array( 'privacy_mode' => false, 'click_to_load' => false, @@ -46,10 +53,10 @@ public function filter_html( string $html, string $url = '', array $attr = array /** * Filters opt-in media embed behavior without changing cached provider HTML. * - * @param array $options Media options: privacy_mode, click_to_load, poster. - * @param string $url Original embed URL. - * @param array $attr Embed attributes. - * @param int $post_id Post ID. + * @param array $options Media options: privacy_mode, click_to_load, poster. + * @param string $url Original embed URL. + * @param array $attr Embed attributes. + * @param int|null $post_id Positive post ID, or null outside a post. */ $filtered = apply_filters( 'emulsify_theme_media_embed_options', $options, $url, $attr, $post_id ); if ( is_array( $filtered ) ) { From 58b567d9af7f325ea063494eefaebd1043fdad06 Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Fri, 23 Oct 2026 23:00:59 -0500 Subject: [PATCH 035/100] fix(blocks): keep block data out of HTML attributes --- .../core-block-twig-renderer-smoke.php | 25 +++++- .github/scripts/twig-autoescape-smoke.php | 83 ++++++++++++++++++- includes/Blocks/CoreBlockTwigRenderer.php | 22 +++-- 3 files changed, 117 insertions(+), 13 deletions(-) diff --git a/.github/scripts/core-block-twig-renderer-smoke.php b/.github/scripts/core-block-twig-renderer-smoke.php index 94cc2b5..3c7707e 100644 --- a/.github/scripts/core-block-twig-renderer-smoke.php +++ b/.github/scripts/core-block-twig-renderer-smoke.php @@ -51,7 +51,7 @@ public static function compile( string $template, array $context ): string { '

        %s:%s:%s

        ', $template, $context['block_name'] ?? '', - $context['attributes']['className'] ?? '' + $context['block_attributes']['className'] ?? '' ); } } @@ -310,6 +310,8 @@ function emulsify_core_block_twig_smoke_remove( string $path ): void { 'blockName' => 'core/paragraph', 'attrs' => array( 'className' => 'intro', + 'level' => 2, + 'anchor' => 'saved-anchor', ), 'innerContent' => array( 'Original' ), 'innerBlocks' => array(), @@ -342,13 +344,15 @@ static function ( array $map ): array { 'emulsify_theme_core_block_twig_context', static function ( array $context ): array { $context['smoke_context'] = true; + $context['legacy_level'] = $context['attributes']['level'] ?? 'empty'; return $context; } ); \Timber\Timber::$context = array( - 'site' => 'Smoke Site', + 'site' => 'Smoke Site', + 'attributes' => array( 'data-global' => 'must not leak' ), ); $enabled = new \Emulsify\Theme\Blocks\CoreBlockTwigRenderer(); @@ -361,9 +365,24 @@ static function ( array $context ): array { $mapped = apply_filters( 'render_block', $original, $block, null ); + emulsify_core_block_twig_smoke_assert( + array() === \Timber\Timber::$compiled[0]['context']['attributes'], + 'Mapped blocks must start with an empty HTML attributes array.' + ); + foreach ( array( 'block_attributes', 'block', 'block_metadata' ) as $key ) { + $actual = \Timber\Timber::$compiled[0]['context'][ $key ]; + emulsify_core_block_twig_smoke_assert( + $block['attrs'] === ( 'block_attributes' === $key ? $actual : $actual['attrs'] ), + 'Mapped block data must remain available through ' . $key . '.' + ); + } + emulsify_core_block_twig_smoke_assert( + 'empty' === \Timber\Timber::$compiled[0]['context']['legacy_level'], + 'Legacy array reads must be empty without throwing or falling back to core markup.' + ); emulsify_core_block_twig_smoke_assert( false !== strpos( $mapped, 'dist/components/paragraph/paragraph.twig:core/paragraph:intro' ), - 'Mapped block should render through Timber compile with attributes.' + 'Mapped block should render through Timber compile with block_attributes.' ); emulsify_core_block_twig_smoke_assert( true === \Timber\Timber::$compiled[0]['context']['smoke_context'], diff --git a/.github/scripts/twig-autoescape-smoke.php b/.github/scripts/twig-autoescape-smoke.php index 3eb9db5..4aa8fdf 100644 --- a/.github/scripts/twig-autoescape-smoke.php +++ b/.github/scripts/twig-autoescape-smoke.php @@ -20,6 +20,7 @@ require_once $autoload; use Emulsify\Theme\Runtime\Twig; +use Emulsify\Theme\Blocks\CoreBlockTwigRenderer; /** * Fails the smoke test with a useful message. @@ -90,4 +91,84 @@ function emulsify_twig_autoescape_fail( string $message ): void { emulsify_twig_autoescape_fail( 'Twig rendered a literal script element from a hostile field value.' ); } -echo "Twig autoescape smoke checks passed; field values were escaped and attribute helpers remained safe HTML.\n"; +/** + * Minimal Timber adapter delegating mapped compilation to the real Twig engine. + */ +final class Emulsify_Autoescape_Timber { + /** @var \Twig\Environment Real Twig environment with the parent's helpers. */ + public static $environment; + + /** @return array Inherited Timber context. */ + public static function context(): array { + return array( 'attributes' => array( 'data-global' => 'must not leak' ) ); + } + + /** + * @param string $template Mapped template. + * @param array $context Renderer context. + * @return string Actual Twig output. + */ + public static function compile( string $template, array $context ): string { + return self::$environment->render( $template, $context ); + } +} + +class_alias( Emulsify_Autoescape_Timber::class, 'Timber\\Timber' ); + +/** @return string Temporary child theme root. */ +function get_stylesheet_directory(): string { + return $GLOBALS['emulsify_autoescape_block_root']; +} + +/** @return string Parent theme root. */ +function get_template_directory(): string { + return dirname( __DIR__, 2 ); +} + +/** + * Supplies a project map without replacing the renderer or helper implementation. + * + * @param string $hook Filter name. + * @param mixed $value Filter value. + * @return mixed Filtered value. + */ +function apply_filters( string $hook, $value ) { + return 'emulsify_theme_core_block_twig_template_map' === $hook ? array( 'core/paragraph' => 'mapped.twig' ) : $value; +} + +$mapped_root = sys_get_temp_dir() . '/emulsify-autoescape-block-' . uniqid( '', true ); +mkdir( $mapped_root ); +$GLOBALS['emulsify_autoescape_block_root'] = $mapped_root; +file_put_contents( $mapped_root . '/mapped.twig', '{% include "mapped-component.twig" %}' ); +file_put_contents( $mapped_root . '/mapped-component.twig', '
        {{ content|raw }}
        ' ); +$environment->setLoader( new \Twig\Loader\ChainLoader( array( $environment->getLoader(), new \Twig\Loader\FilesystemLoader( $mapped_root ) ) ) ); +Emulsify_Autoescape_Timber::$environment = $environment; + +try { + $renderer = new CoreBlockTwigRenderer(); + $mapped = $renderer->render_block( + '

        Hello

        ', + array( + 'blockName' => 'core/paragraph', + 'attrs' => array( + 'onmouseover' => 'alert(document.domain)', + 'level' => 2, + 'className' => 'intro', + 'anchor' => 'saved-anchor', + 'textAlign' => 'center', + ), + ) + ); +} finally { + unlink( $mapped_root . '/mapped.twig' ); + unlink( $mapped_root . '/mapped-component.twig' ); + rmdir( $mapped_root ); + unset( $GLOBALS['emulsify_autoescape_block_root'] ); +} + +$expected_mapped = '

        Hello

        '; +if ( $expected_mapped !== $mapped ) { + emulsify_twig_autoescape_fail( "Mapped block data leaked through helper context.\nExpected: " . $expected_mapped . "\nActual: " . $mapped ); +} + +echo "Twig autoescape smoke checks passed; fields were escaped, helper HTML remained safe, and mapped block data did not become HTML attributes.\n"; diff --git a/includes/Blocks/CoreBlockTwigRenderer.php b/includes/Blocks/CoreBlockTwigRenderer.php index 711fb96..baa9e3e 100644 --- a/includes/Blocks/CoreBlockTwigRenderer.php +++ b/includes/Blocks/CoreBlockTwigRenderer.php @@ -109,19 +109,23 @@ private function context( array $block, string $block_content, $instance, string // Keep WordPress' original rendered content available to Twig templates so // mapped blocks can wrap or progressively replace markup instead of being // forced into an all-or-nothing rewrite. - $context['block'] = $block; - $context['block_metadata'] = $block; - $context['block_name'] = $this->block_name( $block ); - $context['attributes'] = $attributes; - $context['content'] = $block_content; - $context['inner_content'] = $this->inner_content( $block, $block_content ); - $context['inner_blocks'] = isset( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ? $block['innerBlocks'] : array(); - $context['wp_block'] = $instance; - $context['twig_template'] = $template; + $context['block'] = $block; + $context['block_metadata'] = $block; + $context['block_name'] = $this->block_name( $block ); + $context['block_attributes'] = $attributes; + $context['attributes'] = array(); + $context['content'] = $block_content; + $context['inner_content'] = $this->inner_content( $block, $block_content ); + $context['inner_blocks'] = isset( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ? $block['innerBlocks'] : array(); + $context['wp_block'] = $instance; + $context['twig_template'] = $template; /** * Filters Twig context for mapped block rendering. * + * The attributes array is reserved for HTML attributes only and must + * never hold block data or user input. Read block_attributes for block data. + * * @param array $context Twig context. * @param array $block Parsed block metadata. * @param string $block_content Rendered WordPress block content. From 98523f2083bd1a01c0414f5a94d3a1402edc3598 Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Fri, 23 Oct 2026 23:01:28 -0500 Subject: [PATCH 036/100] fix(twig): drop event-handler attribute names --- .github/fixtures/twig-helper-contract.json | 11 ++++++ .github/scripts/attribute-helper-smoke.php | 40 ++++++++++++++++++++++ includes/Support/AttributeBag.php | 2 +- 3 files changed, 52 insertions(+), 1 deletion(-) diff --git a/.github/fixtures/twig-helper-contract.json b/.github/fixtures/twig-helper-contract.json index dd5fbb9..5b49555 100644 --- a/.github/fixtures/twig-helper-contract.json +++ b/.github/fixtures/twig-helper-contract.json @@ -174,6 +174,17 @@ {"field": "contextAfter", "reason": "WordPress leaves source attributes untouched, while Core consumes them."} ] }, + { + "id": "add-event-handler-names", + "helper": "add_attributes", + "args": [{"onclick": "x()", "onMouseOver": "y()", "data-ok": "1"}], + "php": {"attributes": {"data-ok": "1"}, "html": "data-ok=\"1\"", "contextAfter": {}}, + "js": {"attributes": {"onclick": "x()", "onMouseOver": "y()", "data-ok": "1"}, "html": "onclick=\"x()\" onMouseOver=\"y()\" data-ok=\"1\"", "contextAfter": {}}, + "differences": [ + {"field": "attributes", "reason": "The WordPress parent intentionally rejects every case-insensitive on-prefixed attribute name to prevent inline event handlers. Core currently accepts those names."}, + {"field": "html", "reason": "PHP omits event-handler attributes from serialized markup; Core currently serializes them. Both retain data-ok."} + ] + }, { "id": "add-empty", "helper": "add_attributes", diff --git a/.github/scripts/attribute-helper-smoke.php b/.github/scripts/attribute-helper-smoke.php index 9fbd7e3..b3185a1 100644 --- a/.github/scripts/attribute-helper-smoke.php +++ b/.github/scripts/attribute-helper-smoke.php @@ -144,6 +144,46 @@ function emulsify_attribute_helper_parse_parent_templates( Twig $helpers, string $helpers = new Twig(); +$event_attributes = array( + 'onclick' => 'x()', + 'onMouseOver' => 'y()', + 'ONFOCUS' => 'z()', + 'on' => 'ignored', + 'once' => 'ignored', + 'data-onload' => 'allowed', + 'data-ok' => '1', +); +$set_bag = new AttributeBag(); + +foreach ( $event_attributes as $name => $value ) { + $set_bag->set( $name, $value ); +} + +$merge_bag = new AttributeBag(); +$merge_bag->merge( $event_attributes ); + +foreach ( + array( + 'constructor' => new AttributeBag( $event_attributes ), + 'set' => $set_bag, + 'merge' => $merge_bag, + ) as $operation => $event_bag +) { + emulsify_attribute_helper_assert_same( + 'AttributeBag ' . $operation . ' drops event-handler names from its map', + array( + 'data-onload' => 'allowed', + 'data-ok' => '1', + ), + $event_bag->toArray() + ); + emulsify_attribute_helper_assert_same( + 'AttributeBag ' . $operation . ' does not serialize event-handler names', + 'data-onload="allowed" data-ok="1"', + (string) $event_bag + ); +} + emulsify_attribute_helper_assert_same( 'bem positional syntax', 'class="example-card example-card--featured"', diff --git a/includes/Support/AttributeBag.php b/includes/Support/AttributeBag.php index 68b0ead..1f2b481 100644 --- a/includes/Support/AttributeBag.php +++ b/includes/Support/AttributeBag.php @@ -369,7 +369,7 @@ private static function parseClassAttributeString( string $value ): ?string { * @return bool TRUE when safe. */ private static function isSafeAttributeName( string $name ): bool { - return 1 === preg_match( '/^[A-Za-z_:][A-Za-z0-9:_.-]*$/', $name ); + return 0 !== stripos( $name, 'on' ) && 1 === preg_match( '/^[A-Za-z_:][A-Za-z0-9:_.-]*$/', $name ); } /** From 3f6be6a4246565b1dffc39356c49cd123f594593 Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Fri, 23 Oct 2026 23:01:56 -0500 Subject: [PATCH 037/100] docs: document block_attributes and its upgrade action --- UPGRADE.md | 12 ++++++++++++ docs/component-recipes.md | 6 ++++++ docs/core-block-twig-rendering.md | 12 ++++++++++-- docs/release-notes-next.md | 5 +++++ docs/timber-and-twig-authoring.md | 15 +++++++++++++++ 5 files changed, 48 insertions(+), 2 deletions(-) diff --git a/UPGRADE.md b/UPGRADE.md index 57200c7..6bcd1f9 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -55,6 +55,18 @@ remain project-owned. New generated themes receive the updated defaults. removal can reveal an existing link color. Review normal, visited, and focused links against project backgrounds. The visited-link rule and other parent styles and presets remain. +5. **Migrate block-data reads if the project opted into experimental core-block + Twig rendering.** In mapped templates and + `emulsify_theme_core_block_twig_context` filters, replace + `attributes.` reads with `block_attributes.` (in PHP, + `$context['block_attributes']['level']`, for example). Until migrated, those + reads return empty values because `attributes` is now an empty array reserved + for HTML attributes. Block data remains available as `block.attrs` and + `block_metadata.attrs`. Do not copy `anchor` or `className` into HTML + `attributes`: the wrapped WordPress content already carries them, and a + copied anchor would duplicate its ID. The PHP attribute bag also rejects + case-insensitive `on*` names to prevent event-handler attributes. See + [core-block context](docs/core-block-twig-rendering.md#twig-context). Render representative pages as both an administrator and an anonymous visitor, check the editor where blocks are used, and inspect `debug.log`. Storybook diff --git a/docs/component-recipes.md b/docs/component-recipes.md index 54ed823..86e3f14 100644 --- a/docs/component-recipes.md +++ b/docs/component-recipes.md @@ -159,3 +159,9 @@ add_filter( ``` Keep this opt-in narrow. Replacing core block markup can affect block validation, editor expectations, accessibility, and plugin integrations. + +Mapped templates read block data from `block_attributes`, for example +`block_attributes.level` for a heading. The context's `attributes` starts as an +empty array reserved for HTML attributes; do not copy block data into it because +`bem()` and `add_attributes()` merge it into markup. See +[the mapped template context](core-block-twig-rendering.md#twig-context). diff --git a/docs/core-block-twig-rendering.md b/docs/core-block-twig-rendering.md index 284bf27..5dc33e9 100644 --- a/docs/core-block-twig-rendering.md +++ b/docs/core-block-twig-rendering.md @@ -35,13 +35,21 @@ Mapped templates receive the normal Timber context plus these values: - `block`: parsed block metadata from `render_block`. - `block_metadata`: alias for `block`. - `block_name`: block name such as `core/paragraph`. -- `attributes`: parsed block attributes. +- `attributes`: an empty array reserved for HTML attributes. +- `block_attributes`: the parsed block attributes, also available as + `block.attrs` and `block_metadata.attrs`. - `content`: WordPress' original rendered block content. - `inner_content`: rendered inner content fallback for templates. - `inner_blocks`: parsed inner block records when available. - `wp_block`: the `WP_Block` instance when WordPress passes one. - `twig_template`: the mapped Twig template. +Keep block data separate from HTML attributes: `bem()` and `add_attributes()` +merge the context's `attributes` into their output. Never put block data or +untrusted user input there. The renderer does not copy `anchor` or `className` +into this array; WordPress' rendered `content` already carries those values, +and copying an anchor would duplicate its ID. + Use `emulsify_theme_core_block_twig_context` to add project-specific values: ```php @@ -49,7 +57,7 @@ add_filter( 'emulsify_theme_core_block_twig_context', function ( array $context, array $block ): array { if ( 'core/heading' === ( $block['blockName'] ?? '' ) ) { - $context['heading_level'] = $context['attributes']['level'] ?? 2; + $context['heading_level'] = $context['block_attributes']['level'] ?? 2; } return $context; diff --git a/docs/release-notes-next.md b/docs/release-notes-next.md index 8394175..00bff9d 100644 --- a/docs/release-notes-next.md +++ b/docs/release-notes-next.md @@ -64,6 +64,11 @@ lineage. See the [release process](release-process.md). ## Fixed +- Security: experimental mapped core-block rendering keeps parsed block data in + `block_attributes` and starts HTML `attributes` as an empty array, preventing + block comment data from becoming executable HTML attributes through Twig + helpers. The PHP attribute bag also drops all case-insensitive `on*` + event-handler attribute names. - Pagination marks only the actual current page, renders a translated ellipsis character as non-interactive text, and preserves query arguments in numbered, previous, and next links. Numbered links now include a translated `Page ` diff --git a/docs/timber-and-twig-authoring.md b/docs/timber-and-twig-authoring.md index aa804d1..1c4820a 100644 --- a/docs/timber-and-twig-authoring.md +++ b/docs/timber-and-twig-authoring.md @@ -162,6 +162,18 @@ The parent theme registers Core-style helpers for class and attribute handling: The helpers return an attribute bag that serializes safely in Twig string contexts. +Both helpers merge the context's `attributes` with their explicit attributes. +Keep that context value for intentional HTML attributes, never raw block data +or arbitrary user input. Mapped core-block templates receive parsed block data +in `block_attributes`; see [core-block context](core-block-twig-rendering.md#twig-context). + +The PHP attribute bag rejects every attribute name beginning with `on`, +case-insensitively, including `onclick` and `onMouseOver`. Construction, `set()`, +`merge()`, and serialization all enforce this rule. This is intentional +WordPress PHP hardening: Core's JavaScript helpers and Emulsify Drupal still +accept these names. Attribute-value escaping alone does not make event-handler +attributes safe. + Argumentless Twig calls such as `{{ add_attributes() }}` and `{{ bem() }}` use only the context's `attributes`. Other context variables are not attributes or BEM options. The registered callbacks keep that injected context separate from @@ -213,6 +225,7 @@ Selected differences remain explicit: | Numeric `0` as an extra class | `_0` | Omitted | | `true` in an `add_attributes()` class list | `_1` | `true` | | `true` in a non-class attribute list | `1` | `true` | +| Attribute names starting with `on`, regardless of case | Dropped | Accepted | | Caller attributes supplied as an array or attribute bag | Copied; remain in caller context | Consumed from invocation context | The fixture cases `bem-boolean-and-numeric-values`, @@ -222,6 +235,8 @@ The fixture cases `bem-boolean-and-numeric-values`, attributes again on a second call, whereas Core has consumed them. WordPress's own `AttributeBag` preserves the caller context too; changing from an array to that bag does not provide print-once behavior. +`add-event-handler-names` records the intentional attribute-name hardening +difference in both the returned map and serialized HTML. Both implementations currently treat boolean ARIA values like HTML booleans: `true` can produce a bare attribute, and `false` can be omitted rather than From 011ec310f17db06d3363fa3bda415a99f0774d88 Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Fri, 23 Oct 2026 23:13:26 -0500 Subject: [PATCH 038/100] fix(theme): remove failing and inert quote and button styles --- theme.json | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/theme.json b/theme.json index a224f58..057408c 100644 --- a/theme.json +++ b/theme.json @@ -590,11 +590,7 @@ "core/button": { "border": { "radius": "0", - "width": "0", - "color": { - "background": "var(--wp--preset--color--grayscale-black)", - "text": "var(--wp--preset--color--grayscale-white)" - } + "width": "0" }, "spacing": { "padding": { @@ -606,14 +602,11 @@ } }, "core/quote": { - "color": { - "text": "var(--wp--preset--color--brand-primary-light)" - }, "spacing": { "margin": { "top": "0", "right": "0", - "bottom": "var(--wp--preset--spacing--medium)", + "bottom": "0", "left": "0" } }, @@ -634,7 +627,7 @@ }, "spacing": { "margin": { - "top": "var(--wp--preset--spacing--medium)" + "top": "0" } } } From a6c9d2eb3876a00efb5e1753054434e7da993290 Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Fri, 23 Oct 2026 23:14:27 -0500 Subject: [PATCH 039/100] test(theme): check theme.json style values and preset references --- .github/scripts/release-check.cjs | 10 ++ .github/scripts/theme-json-style-contract.cjs | 82 ++++++++++++++++ .../theme-json-style-contract.test.cjs | 97 +++++++++++++++++++ 3 files changed, 189 insertions(+) create mode 100644 .github/scripts/theme-json-style-contract.cjs create mode 100644 .github/scripts/theme-json-style-contract.test.cjs diff --git a/.github/scripts/release-check.cjs b/.github/scripts/release-check.cjs index 382ef9a..354ba82 100644 --- a/.github/scripts/release-check.cjs +++ b/.github/scripts/release-check.cjs @@ -10,6 +10,7 @@ const childProcess = require('child_process'); const yaml = require('js-yaml'); const { validateReleaseVersionContract } = require('./release-version-contract.cjs'); const { validateReadinessMatrix } = require('./readiness-matrix-contract.cjs'); +const { validateThemeJsonStyles } = require('./theme-json-style-contract.cjs'); const repoRoot = path.resolve(__dirname, '../..'); const skipSmoke = process.argv.slice(2).includes('--skip-smoke'); @@ -320,6 +321,8 @@ function runStaticChecks() { '.github/scripts/release-version-contract.test.cjs', '.github/scripts/readiness-matrix-contract.cjs', '.github/scripts/readiness-matrix-contract.test.cjs', + '.github/scripts/theme-json-style-contract.cjs', + '.github/scripts/theme-json-style-contract.test.cjs', '.github/scripts/semantic-release.test.cjs', '.github/scripts/fallback-navigation-smoke.php', '.github/scripts/rendered-a11y.cjs', @@ -1797,6 +1800,13 @@ function runStaticChecks() { return 'No .DS_Store files were found outside .git.'; }); + runStaticCheck('Theme JSON styles', () => { + const errors = validateThemeJsonStyles(readJson('theme.json')); + ensure(errors.length === 0, errors.join('\n')); + return 'Style values are scalar where required and WordPress preset references resolve to settings.'; + }); + + runCommandCheck('Theme JSON style contract tests', process.execPath, ['--test', '.github/scripts/theme-json-style-contract.test.cjs']); runCommandCheck('Release version contract tests', process.execPath, ['--test', '.github/scripts/release-version-contract.test.cjs']); runCommandCheck('Readiness matrix contract tests', process.execPath, ['--test', '.github/scripts/readiness-matrix-contract.test.cjs']); runCommandCheck('Semantic release guard tests', process.execPath, ['--test', '.github/scripts/semantic-release.test.cjs']); diff --git a/.github/scripts/theme-json-style-contract.cjs b/.github/scripts/theme-json-style-contract.cjs new file mode 100644 index 0000000..6cb2adc --- /dev/null +++ b/.github/scripts/theme-json-style-contract.cjs @@ -0,0 +1,82 @@ +const fs = require('node:fs'); +const path = require('node:path'); + +const PRESET_PATHS = { + color: ['color', 'palette'], + 'font-size': ['typography', 'fontSizes'], + 'font-family': ['typography', 'fontFamilies'], + spacing: ['spacing', 'spacingSizes'], + gradient: ['color', 'gradients'], + shadow: ['shadow', 'presets'], + 'aspect-ratio': ['dimensions', 'aspectRatios'], +}; + +// Scalar leaves from WordPress 6.8 WP_Theme_JSON's style property mappings. +// Radius, spacing, block gap and background image also accept nested objects. +const SCALAR_PATHS = [ + 'background.backgroundPosition', 'background.backgroundRepeat', + 'background.backgroundSize', 'background.backgroundAttachment', + 'background.backgroundImage.url', + 'color.background', 'color.text', 'color.gradient', + 'dimensions.aspectRatio', 'dimensions.minHeight', 'filter.duotone', + 'layout.contentSize', 'layout.wideSize', + ...['color', 'style', 'width'].flatMap((property) => [ + `border.${property}`, + ...['top', 'right', 'bottom', 'left'].map((side) => `border.${side}.${property}`), + ]), + ...['topLeft', 'topRight', 'bottomLeft', 'bottomRight'].map((corner) => `border.radius.${corner}`), + ...['color', 'offset', 'style', 'width'].map((property) => `outline.${property}`), + ...['margin', 'padding'].flatMap((property) => + ['top', 'right', 'bottom', 'left'].map((side) => `spacing.${property}.${side}`)), + 'spacing.blockGap.top', 'spacing.blockGap.left', 'shadow', + ...['fontFamily', 'fontSize', 'fontStyle', 'fontWeight', 'letterSpacing', + 'lineHeight', 'textAlign', 'textColumns', 'textDecoration', 'textTransform', + 'writingMode'].map((property) => `typography.${property}`), +]; + +function validateThemeJsonStyles(theme) { + const errors = []; + + function visit(value, segments, settings, stylePath = []) { + const location = segments.join('.'); + if (value !== null && typeof value === 'object') { + if (SCALAR_PATHS.includes(stylePath.join('.'))) { + errors.push(`${location} must be a single style value, not an object or array.`); + } + for (const [key, child] of Object.entries(value)) { + const childSettings = segments.at(-1) === 'blocks' + ? [theme.settings?.blocks?.[key], ...settings] + : settings; + const startsStyle = ['blocks', 'elements', 'variations'].includes(stylePath.at(-1)) || key.startsWith(':'); + visit(child, [...segments, key], childSettings, startsStyle ? [] : [...stylePath, key]); + } + return; + } + if (typeof value !== 'string') return; + if (value.includes('---wp')) errors.push(`${location} uses the malformed ---wp custom-property prefix.`); + for (const match of value.matchAll(/var\(\s*--wp--preset--([a-z-]+?)--([A-Za-z0-9_-]+)/g)) { + const [, type, slug] = match; + const presetPath = PRESET_PATHS[type]; + const defined = presetPath && settings.some((scope) => { + const presets = presetPath.reduce((entry, key) => entry?.[key], scope); + return Array.isArray(presets) && presets.some((preset) => String(preset.slug) === slug); + }); + if (!defined) errors.push(`${location} references undefined preset --wp--preset--${type}--${slug}.`); + } + } + + visit(theme.styles || {}, ['styles'], [theme.settings]); + return errors; +} + +if (require.main === module) { + const filename = path.resolve(process.argv[2] || path.join(__dirname, '../../theme.json')); + const errors = validateThemeJsonStyles(JSON.parse(fs.readFileSync(filename, 'utf8'))); + if (errors.length) { + console.error(errors.join('\n')); + process.exitCode = 1; + } + else console.log('theme.json style values and preset references are valid.'); +} + +module.exports = { validateThemeJsonStyles }; diff --git a/.github/scripts/theme-json-style-contract.test.cjs b/.github/scripts/theme-json-style-contract.test.cjs new file mode 100644 index 0000000..473e4f9 --- /dev/null +++ b/.github/scripts/theme-json-style-contract.test.cjs @@ -0,0 +1,97 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); +const { validateThemeJsonStyles } = require('./theme-json-style-contract.cjs'); + +function fixture() { + return { + settings: { + color: { palette: [{ slug: 'brand', color: '#123456' }] }, + typography: { + fontSizes: [{ slug: 'large', size: '2rem' }], + fontFamilies: [{ slug: 'primary', fontFamily: 'sans-serif' }], + }, + spacing: { spacingSizes: [{ slug: '4', size: '1rem' }] }, + }, + styles: { + color: { text: 'var(--wp--preset--color--brand)' }, + typography: { + fontSize: 'var(--wp--preset--font-size--large)', + fontFamily: 'var(--wp--preset--font-family--primary)', + }, + spacing: { padding: { top: 'var(--wp--preset--spacing--4)' } }, + }, + }; +} + +test('accepts defined color, font size, font family and spacing presets', () => { + assert.deepEqual(validateThemeJsonStyles(fixture()), []); +}); + +test('rejects undefined references for every required preset type', () => { + for (const type of ['color', 'font-size', 'font-family', 'spacing']) { + const theme = fixture(); + theme.styles.elements = { link: { ':hover': { color: { text: `var(--wp--preset--${type}--missing)` } } } }; + assert.deepEqual(validateThemeJsonStyles(theme), [ + `styles.elements.link.:hover.color.text references undefined preset --wp--preset--${type}--missing.`, + ]); + } +}); + +test('checks every reference in composite CSS values, including fallback values', () => { + const theme = fixture(); + theme.styles.spacing.margin = 'calc(var( --wp--preset--spacing--4) + var(--wp--preset--spacing--missing, var(--wp--preset--spacing--absent)))'; + assert.equal(validateThemeJsonStyles(theme).length, 2); +}); + +test('rejects malformed WordPress custom-property prefixes', () => { + const theme = fixture(); + theme.styles.color.text = 'var(---wp--preset--color--brand)'; + assert.match(validateThemeJsonStyles(theme)[0], /malformed ---wp/); +}); + +test('rejects objects and arrays in scalar style properties at every nesting level', () => { + for (const invalid of [{ text: '#fff' }, []]) { + const theme = fixture(); + theme.styles.blocks = { + 'core/button': { border: { color: invalid } }, + 'core/quote': { + elements: { cite: { typography: { fontSize: invalid } } }, + border: { bottom: { width: invalid } }, + spacing: { margin: { top: invalid } }, + }, + }; + const errors = validateThemeJsonStyles(theme); + assert.equal(errors.length, 4); + assert.ok(errors.every((error) => error.includes('must be a single style value'))); + } +}); + +test('accepts legitimate nested border, spacing, background and element containers', () => { + const theme = fixture(); + theme.styles.blocks = { + 'core/button': { + border: { color: '#fff', top: { color: '#000', width: '1px', style: 'solid' }, radius: { topLeft: '2px', bottomRight: '3px' } }, + background: { backgroundImage: { url: 'https://example.org/image.png', source: 'file' } }, + spacing: { margin: { bottom: '0' }, padding: '1rem', blockGap: { top: '1rem', left: '2rem' } }, + elements: { link: { ':focus': { outline: { color: '#000', width: '2px' } } } }, + variations: { shadow: { shadow: '0 1px 2px #000', color: { text: '#000' } } }, + }, + }; + assert.deepEqual(validateThemeJsonStyles(theme), []); +}); + +test('accepts block-specific presets only in their block scope', () => { + const theme = fixture(); + theme.settings.blocks = { 'core/button': { color: { palette: [{ slug: 'button', color: '#000' }] } } }; + theme.styles.blocks = { 'core/button': { color: { text: 'var(--wp--preset--color--button)' } } }; + assert.deepEqual(validateThemeJsonStyles(theme), []); + theme.styles.color.text = 'var(--wp--preset--color--button)'; + assert.equal(validateThemeJsonStyles(theme).length, 1); +}); + +test('leaves settings metadata and custom variables outside the style-value contract', () => { + const theme = fixture(); + theme.settings.custom = { example: 'var(--wp--preset--color--missing)' }; + theme.styles.color.background = 'var(--project-background, #fff)'; + assert.deepEqual(validateThemeJsonStyles(theme), []); +}); From 7e79db4ca1eeccbf3790dc766b6f6b87f26e483e Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Fri, 23 Oct 2026 23:14:56 -0500 Subject: [PATCH 040/100] docs: describe the global styles the parent still produces --- AGENTS.md | 11 ++++++++--- UPGRADE.md | 16 +++++++++++++--- docs/accessibility.md | 14 ++++++++++---- docs/release-notes-next.md | 8 ++++++-- docs/sister-project-parity.md | 21 +++++++++++++++++---- 5 files changed, 54 insertions(+), 16 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2006c21..afc4778 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,9 +26,14 @@ in `docs/`, especially [sister-project parity](docs/sister-project-parity.md), The parent owns reusable WordPress integration, context, discovery, assets, and fallback rendering. Project-specific templates, components, stories, styling, and content mappings belong in the generated child; functionality -independent of the theme belongs in a project plugin. The parent adds no -frontend CSS. Keep WordPress behavior native rather than importing Drupal -names or mechanics. +independent of the theme belongs in a project plugin. The parent ships no +stylesheet, but its `theme.json` produces WordPress global styles on the +frontend: h1–h6 color and typography, visited-link color, root padding, +button border radius/width and padding, and quote/cite typography and margins +plus cite color. It also produces preset custom properties and `.has-*` +classes, which style nothing until content uses them. Projects own final +presentation, target size, `screen-reader-text`, and focus styles. Keep +WordPress behavior native rather than importing Drupal names or mechanics. Keep the starter component-library-neutral. Discover the selected library's directories, APIs, namespaces, tokens, stories, and build inputs from its guide diff --git a/UPGRADE.md b/UPGRADE.md index 6bcd1f9..f16aafb 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -45,7 +45,13 @@ remain project-owned. New generated themes receive the updated defaults. Explicit-argument calls and the direct PHP helper API are unchanged. 4. **Check the rendered site's accessibility and link states.** Projects own `screen-reader-text`, visible focus styles, and pointer target sizing. The - parent adds no frontend CSS to supply them. The rendered audit disables + parent ships no stylesheet, but its `theme.json` produces WordPress global + styles on the frontend: h1–h6 color and typography, visited-link color, + root padding, button border radius/width and padding, and quote/cite + typography and margins plus cite color. It also produces preset custom + properties and `.has-*` classes, which style nothing until content uses + them. These rules do not supply the project's accessibility helpers. The + rendered audit disables only axe's `target-size` rule; that exception does not establish WCAG 2.2 SC 2.5.8 compliance. See [project responsibilities](docs/accessibility.md#project-responsibilities). @@ -53,8 +59,12 @@ remain project-owned. New generated themes receive the updated defaults. contrast on white. Its unresolved hover, focus, and active declarations have also been removed; those declarations caused color inheritance, so removal can reveal an existing link color. Review normal, visited, and - focused links against project backgrounds. The visited-link rule and other - parent styles and presets remain. + focused links against project backgrounds. Quote text now inherits the + page's text color instead of the pale blue that failed contrast on white; + review project quote presentation. The removed button color object never + applied, and quote spacing is unchanged: the previously unresolved bottom + quote and top cite margins are now explicitly `0`. Other styles and + presets remain. 5. **Migrate block-data reads if the project opted into experimental core-block Twig rendering.** In mapped templates and `emulsify_theme_core_block_twig_context` filters, replace diff --git a/docs/accessibility.md b/docs/accessibility.md index a421b87..c6ae314 100644 --- a/docs/accessibility.md +++ b/docs/accessibility.md @@ -1,8 +1,13 @@ # Accessibility checks The parent theme supplies semantic fallback markup and WordPress integration. -It adds no frontend CSS. Projects own the final visual presentation, target -sizes, focus indicators, and any visually hidden text helpers their pages need. +It ships no stylesheet, but its `theme.json` produces WordPress global styles +on the frontend: h1–h6 color and typography, visited-link color, root padding, +button border radius/width and padding, and quote/cite typography and margins +plus cite color. It also produces preset custom properties and `.has-*` +classes, which style nothing until content uses them. Projects own the final +visual presentation, target sizes, focus indicators, and any visually hidden +text helpers their pages need. ## Check Storybook and WordPress @@ -102,8 +107,9 @@ render/navigation assertions, and any reported violation of the enabled rules fail the audit. The sole disabled axe rule is `target-size`. This is a narrow test-policy -exception for the unstyled parent fallback: the parent does not add frontend -CSS to enlarge or space links. It is not an exemption from WCAG or a claim that +exception for parent fallbacks without target-size styling: the retained +global styles do not enlarge or space navigation links for this criterion. +It is not an exemption from WCAG or a claim that the rendered site meets Target Size (Minimum). No other selected rule is disabled. diff --git a/docs/release-notes-next.md b/docs/release-notes-next.md index 00bff9d..0574bbb 100644 --- a/docs/release-notes-next.md +++ b/docs/release-notes-next.md @@ -85,8 +85,12 @@ lineage. See the [release process](release-process.md). - The document shell preserves trusted WordPress language attributes as markup, producing a valid `lang` attribute. - Parent `theme.json` no longer sets the failing pale-blue normal link color or - invalid hover, focus, and active color declarations. Other styles and editor - presets remain; review project link presentation after updating. + invalid hover, focus, and active color declarations. Quote text now inherits + the page's text color instead of the same failing pale blue; review project + quote presentation. The removed button color object never applied, and + quote spacing is unchanged: previously unresolved bottom quote and top cite + margins are now explicitly `0`. Other global styles and editor presets + remain; review project link presentation after updating. - Generated audit wrappers keep JSON stdout clean by sending documentation footers to stderr, while preserving arguments and Core's exit status. - Release tooling accepts prepared versions after `2.0.0`, rejects stale draft diff --git a/docs/sister-project-parity.md b/docs/sister-project-parity.md index 5bf6ebd..c09d232 100644 --- a/docs/sister-project-parity.md +++ b/docs/sister-project-parity.md @@ -115,12 +115,20 @@ correct fallback language markup and failing link-color declarations. See the | Sister feature | WordPress decision and reason | | --- | --- | -| 24 px target-size rule and Drupal's parent asset build | Skip: the parent adds no frontend CSS to the surrounding page; projects own target size, `screen-reader-text`, and focus styles. Admin-only CSS is allowed. | +| 24 px target-size rule and Drupal's parent asset build | Skip: the parent ships no stylesheet; its retained `theme.json` global styles are listed below, and projects own target size, `screen-reader-text`, and focus styles. Admin-only CSS is allowed. | | Regions: `status`, `breadcrumb`, `highlighted`, `sidebar_first`, `sidebar_second` | Skip: WordPress uses menus, block patterns, and template parts; the parent registers no widget areas. Maintainers may revisit this choice. | | Container and form template suggestions, inline form-error IDs, status-message/feed/progress template moves, and select option attributes | Skip: these belong to Drupal's render and Form APIs. | | Favicon dark-mode icons, SVG sanitization, and generated social share image | Skip: WordPress uses core Site Icon and `custom-logo`; SEO plugins own `og:image`. | | Default parent logo, library/breakpoint contract, Drupal.org channel-skew validation, Stable 9 parity, and the `dr` CLI | Skip: these are Drupal-only runtime and distribution contracts. | +Although the parent ships no stylesheet, its `theme.json` produces WordPress +global styles on the frontend: h1–h6 color and typography, visited-link color, +root padding, button border radius/width and padding, and quote/cite typography +and margins plus cite color. These remaining style rules are retained in 2.1.0 +and planned for removal in 3.0.0. Preset custom properties and `.has-*` classes +are also generated; they style nothing until content uses them. Presets and +editor configuration remain separate from that style-removal plan. + The opt-in video poster is a separate iframe document with its own inline accessibility styles, as approved for that feature. It adds no stylesheet to the surrounding page; its CSP and override contract are documented in @@ -148,7 +156,12 @@ Use this contract when evaluating future changes: - Keep Whisk small enough to work as a starter. - Keep generated child theme source aligned with Emulsify Core 4 conventions. - Keep WordPress-specific behavior explicit rather than hiding it behind Drupal naming or assumptions. -- The parent adds no frontend CSS to the surrounding page. Admin-only CSS is - allowed; projects own target size, `screen-reader-text`, and focus styles. - Opt-in video posters confine their accessibility styles to the iframe document. +- The parent ships no stylesheet, but its `theme.json` produces frontend + WordPress global styles: h1–h6 color and typography, visited-link color, + root padding, button border radius/width and padding, and quote/cite + typography and margins plus cite color. These rules are retained in 2.1.0 + and planned for removal in 3.0.0. Preset custom properties and `.has-*` + classes style nothing until content uses them. Admin-only CSS is allowed; + projects own target size, `screen-reader-text`, and focus styles. Opt-in + video posters confine their accessibility styles to the iframe document. - Keep `project.emulsify.json` on `"platform": "wordpress"` and `generatedFrom: "emulsify-wordpress"` so generated child themes advertise the WordPress platform adapter and starter lineage. From 62a21cfec8c90f05ea626ff4298f14d3666720fa Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Fri, 23 Oct 2026 23:30:08 -0500 Subject: [PATCH 041/100] fix(templates): decode page-number entities in pagination --- .github/scripts/fallback-navigation-smoke.php | 29 +++++++++++++++---- UPGRADE.md | 3 ++ docs/release-notes-next.md | 2 ++ templates/partials/pagination.twig | 4 +-- 4 files changed, 31 insertions(+), 7 deletions(-) diff --git a/.github/scripts/fallback-navigation-smoke.php b/.github/scripts/fallback-navigation-smoke.php index cd87af1..a0cf461 100644 --- a/.github/scripts/fallback-navigation-smoke.php +++ b/.github/scripts/fallback-navigation-smoke.php @@ -111,11 +111,12 @@ function emulsify_navigation_expect( bool $condition, string $message, array &$e ); $pager_dom = emulsify_navigation_dom( $pagination ); $checks = array( - 'pagination current page' => array(), - 'pagination literal ellipsis' => array(), - 'pagination page prefixes' => array(), - 'pagination URL escaping' => array(), - 'menu current item' => array(), + 'pagination current page' => array(), + 'pagination literal ellipsis' => array(), + 'pagination page prefixes' => array(), + 'pagination URL escaping' => array(), + 'pagination localized numbers' => array(), + 'menu current item' => array(), ); $current = $pager_dom->query( '//nav//*[@aria-current="page"]' ); @@ -155,6 +156,24 @@ function emulsify_navigation_expect( bool $condition, string $message, array &$e emulsify_navigation_expect( $translated_prefixes->length > 0 && $hostile . ' ' === $translated_prefixes->item( 0 )->textContent && 0 === $translated_dom->query( '//script' )->length, 'Translated Page prefixes must remain HTML escaped and retain their separating space.', $checks['pagination page prefixes'] ); unset( $GLOBALS['emulsify_navigation_translation_overrides'] ); +$localized_pagination = $environment->render( + '@templates/partials/pagination.twig', + array( + 'pagination' => array( + 'pages' => array( + array( 'title' => '1 000', 'current' => true ), + array( 'title' => '1 001', 'link' => '/?paged=1001', 'current' => false ), + ), + ), + ) +); +$localized_dom = emulsify_navigation_dom( $localized_pagination ); +$localized_current = $localized_dom->query( '//nav//span[@aria-current="page"]' ); +$localized_link = $localized_dom->query( '//nav//a' ); +emulsify_navigation_expect( 1 === $localized_current->length && "1\u{00A0}000" === $localized_current->item( 0 )->textContent, 'Current page numbers must render a non-breaking thousands separator.', $checks['pagination localized numbers'] ); +emulsify_navigation_expect( 1 === $localized_link->length && "Page 1\u{00A0}001" === $localized_link->item( 0 )->textContent, 'Linked page numbers must retain the Page prefix and a non-breaking thousands separator.', $checks['pagination localized numbers'] ); +emulsify_navigation_expect( false === strpos( $localized_pagination, ' ' ) && false === strpos( $localized_pagination, '&nbsp;' ), 'Localized numbers must contain the actual non-breaking space, not literal or double-escaped entity text.', $checks['pagination localized numbers'] ); + $expected_urls = array_values( $urls ); foreach ( $links as $index => $link ) { $title = trim( $link->textContent ); diff --git a/UPGRADE.md b/UPGRADE.md index f16aafb..b7cb802 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -31,6 +31,9 @@ remain project-owned. New generated themes receive the updated defaults. `aria-current="page"`; display a translated literal `…` in the existing non-interactive span; decode Timber's display-escaped URL entities before passing every page, previous, and next URL to `add_attributes`. + Decode numbered `page.title` entities once before Twig autoescaping, + for both linked and current numbers, so localized non-breaking thousands + separators display correctly. Never apply `|raw` to these titles. - Numbered pagination links: prefix the number with translated `Page ` in `span.screen-reader-text`. Keep the current-page span, ellipsis, and Previous/Next text unchanged. diff --git a/docs/release-notes-next.md b/docs/release-notes-next.md index 0574bbb..da3c394 100644 --- a/docs/release-notes-next.md +++ b/docs/release-notes-next.md @@ -73,6 +73,8 @@ lineage. See the [release process](release-process.md). character as non-interactive text, and preserves query arguments in numbered, previous, and next links. Numbered links now include a translated `Page ` prefix in a `screen-reader-text` span, with no added frontend CSS. +- Localized pagination numbers decode HTML entities before autoescaping, so + non-breaking thousands separators render correctly instead of literal ` `. - Fallback menus mark the current item with `aria-current="page"`, leaving ancestors unmarked. - Project component references follow Core's direct candidates and breadth-first diff --git a/templates/partials/pagination.twig b/templates/partials/pagination.twig index 6c06b33..68a0fe8 100644 --- a/templates/partials/pagination.twig +++ b/templates/partials/pagination.twig @@ -38,9 +38,9 @@ } %}
      • {% if page.link|default(null) %} - {{ function('__', 'Page', 'emulsify') }} {{ page.title }} + {{ function('__', 'Page', 'emulsify') }} {{ function('html_entity_decode', page.title) }} {% else %} - {{ page.class|default('') == 'dots' ? function('__', '…', 'emulsify') : page.title }} + {{ page.class|default('') == 'dots' ? function('__', '…', 'emulsify') : function('html_entity_decode', page.title) }} {% endif %}
      • {% endfor %} From 21dd39f9fa24ffa3e260926e1b6f13761e70bf4a Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Fri, 23 Oct 2026 23:36:22 -0500 Subject: [PATCH 042/100] fix(media): declare the poster document's text direction --- .github/scripts/media-embed-smoke.php | 27 ++++++++++++++++++++++++++- docs/media-embeds.md | 14 ++++++++++---- includes/Media/Embeds.php | 13 +++++++------ templates/partials/oembed-poster.twig | 2 +- 4 files changed, 44 insertions(+), 12 deletions(-) diff --git a/.github/scripts/media-embed-smoke.php b/.github/scripts/media-embed-smoke.php index d2b5b7e..145989c 100644 --- a/.github/scripts/media-embed-smoke.php +++ b/.github/scripts/media-embed-smoke.php @@ -159,6 +159,31 @@ function emulsify_media_smoke_markup( string $source, string $title = 'Fixture v emulsify_media_smoke_assert( null === $embed_context[2], 'Non-integer post IDs must normalize to null.' ); } + // Simulate an Arabic interface without installing language packs. Restore it + // before a second render to catch language or direction cached per request. + $default_language = get_bloginfo( 'language' ); + $default_direction = $GLOBALS['wp_locale']->text_direction; + $rtl_locale = static function (): string { + return 'ar'; + }; + $options = array( 'click_to_load' => true, 'poster' => 'neutral' ); + emulsify_media_smoke_assert( 'ar' !== $default_language && 'ltr' === $default_direction, 'The locale fixture must start with a distinct left-to-right site default.' ); + try { + add_filter( 'locale', $rtl_locale, 999 ); + $GLOBALS['wp_locale']->text_direction = 'rtl'; + $output = apply_filters( 'embed_oembed_html', emulsify_media_smoke_markup( $fixtures['youtube']['source'] ), $fixtures['youtube']['url'], array(), 0 ); + $document = emulsify_media_smoke_document( (string) emulsify_media_smoke_attribute( $output, 'srcdoc' ) ); + emulsify_media_smoke_assert( 'ar' === $document->documentElement->getAttribute( 'lang' ), 'Poster documents must declare the Arabic interface language rather than the site default.' ); + emulsify_media_smoke_assert( 'rtl' === $document->documentElement->getAttribute( 'dir' ), 'Poster documents must declare the right-to-left interface direction.' ); + } finally { + remove_filter( 'locale', $rtl_locale, 999 ); + $GLOBALS['wp_locale']->text_direction = $default_direction; + } + $output = apply_filters( 'embed_oembed_html', emulsify_media_smoke_markup( $fixtures['youtube']['source'] ), $fixtures['youtube']['url'], array(), 0 ); + $document = emulsify_media_smoke_document( (string) emulsify_media_smoke_attribute( $output, 'srcdoc' ) ); + emulsify_media_smoke_assert( $default_language === $document->documentElement->getAttribute( 'lang' ) && 'ltr' === $document->documentElement->getAttribute( 'dir' ), 'A second poster must return to the default language and left-to-right direction after locale restoration.' ); + $options = array(); + // Cached data comes from WordPress's oEmbed parse pipeline, including the // unlisted video's h value rather than merging distinct videos by ID. $metadata = (object) array( @@ -371,7 +396,7 @@ function emulsify_media_smoke_markup( string $source, string $title = 'Fixture v } emulsify_media_smoke_assert( $empty_rejected, 'An empty project poster must fail closed instead of silently loading a third-party player.' ); - echo "PASS media embeds: missing post IDs and no-post shortcodes, normalized public filter context, off defaults, raw parameter preservation, privacy, translated srcdoc posters, safe URLs, asynchronous metadata, classic/block filters, child overrides, and fail-closed poster errors.\n"; + echo "PASS media embeds: missing post IDs and no-post shortcodes, normalized public filter context, off defaults, raw parameter preservation, privacy, translated srcdoc posters with Arabic RTL and restored LTR locales, safe URLs, asynchronous metadata, classic/block filters, child overrides, and fail-closed poster errors.\n"; } finally { remove_filter( 'emulsify_theme_media_embed_options', $options_hook, 999 ); remove_filter( 'gettext', $translation, 999 ); diff --git a/docs/media-embeds.md b/docs/media-embeds.md index 3e5ee02..1104413 100644 --- a/docs/media-embeds.md +++ b/docs/media-embeds.md @@ -78,14 +78,20 @@ The template receives a `media_embed` map: | `title` | Video title from the iframe or cached Vimeo metadata, falling back to a translated provider label; used in the document and hidden link label. | | `provider` | `YouTube` or `Vimeo`. | | `thumbnail_url` | HTTPS image URL, or an empty string for a neutral poster. | -| `language_code` | Current WordPress locale formatted for the document's `lang`. | +| `language_code` | WordPress locale used for the poster's translated strings, formatted for the document's `lang`. | +| `language_direction` | `rtl` or `ltr` for that locale, used for the document's `dir`. | | `play_label` | Translated `Play video` text. | +Language and direction are computed for each render and follow WordPress locale +switching with `switch_to_locale()`. Poster overrides must keep both `lang` and +`dir` on the document's `` element. + The fallback supplies its own inline CSS inside the frame. It adds no stylesheet to the surrounding page. Its translated play link has a visible focus ring, a -visually hidden video title, and a document language. Controls become compact in -frames 160 pixels high or shorter. Keep these accessibility details and Twig -autoescaping in an override; provider titles and URLs are not trusted HTML. +visually hidden video title, and a document language and direction. Controls +become compact in frames 160 pixels high or shorter. Keep these accessibility +details and Twig autoescaping in an override; provider titles and URLs are not +trusted HTML. An invalid or empty poster template raises a rendering error. Fix or remove that override before enabling click to load; an error does not silently fall back to requesting the provider's player. diff --git a/includes/Media/Embeds.php b/includes/Media/Embeds.php index 957fbd9..300a508 100644 --- a/includes/Media/Embeds.php +++ b/includes/Media/Embeds.php @@ -106,12 +106,13 @@ public function filter_html( $html, $url = '', $attr = array(), $post_id = 0 ) { $language = get_bloginfo( 'language' ); $context = array( 'media_embed' => array( - 'player_url' => $this->set_parameter( $player_url, 'autoplay' ), - 'title' => $title, - 'provider' => 'youtube' === $player['provider'] ? 'YouTube' : 'Vimeo', - 'thumbnail_url' => $thumbnail, - 'language_code' => '' !== $language ? $language : 'en', - 'play_label' => __( 'Play video', 'emulsify' ), + 'player_url' => $this->set_parameter( $player_url, 'autoplay' ), + 'title' => $title, + 'provider' => 'youtube' === $player['provider'] ? 'YouTube' : 'Vimeo', + 'thumbnail_url' => $thumbnail, + 'language_code' => '' !== $language ? $language : 'en', + 'language_direction' => is_rtl() ? 'rtl' : 'ltr', + 'play_label' => __( 'Play video', 'emulsify' ), ), ); $document = \Timber\Timber::compile( '@templates/partials/oembed-poster.twig', $context ); diff --git a/templates/partials/oembed-poster.twig b/templates/partials/oembed-poster.twig index d343896..04432c5 100644 --- a/templates/partials/oembed-poster.twig +++ b/templates/partials/oembed-poster.twig @@ -1,6 +1,6 @@ {# Standalone iframe srcdoc; see docs/media-embeds.md before overriding. #} - + From d1d343b5198241d73f47c36a6cf4bdbe62887a07 Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Fri, 23 Oct 2026 23:36:28 -0500 Subject: [PATCH 043/100] docs: record the Drupal 1bc8fb3 sync --- docs/release-notes-next.md | 3 ++- docs/sister-project-parity.md | 12 +++++++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/docs/release-notes-next.md b/docs/release-notes-next.md index da3c394..ca959c0 100644 --- a/docs/release-notes-next.md +++ b/docs/release-notes-next.md @@ -25,7 +25,8 @@ lineage. See the [release process](release-process.md). and Vimeo oEmbeds. Both default to off. The `emulsify_theme_media_embed_options` filter controls privacy, activation, and provider or neutral posters; children can override the accessible `srcdoc` - document. See [media embeds](media-embeds.md) for CSP and privacy requirements. + document. Posters declare the document's language and direction. See + [media embeds](media-embeds.md) for CSP and privacy requirements. - Public `site_header`, `main`, and `page_title` Twig blocks let children extend the parent layout without copying it. Existing block names, boundaries, and default output are preserved and tested. See diff --git a/docs/sister-project-parity.md b/docs/sister-project-parity.md index c09d232..41cdee2 100644 --- a/docs/sister-project-parity.md +++ b/docs/sister-project-parity.md @@ -85,7 +85,7 @@ This file may also hold Emulsify Core metadata such as `variant.structureImpleme | Sister reference | WordPress target | Status | | --- | --- | --- | -| Drupal 7.3.0: [7.2.2](https://github.com/emulsify-ds/emulsify-drupal/tree/7.2.2) plus [PR #382](https://github.com/emulsify-ds/emulsify-drupal/pull/382), pinned at [`f5d7d4a`](https://github.com/emulsify-ds/emulsify-drupal/commit/f5d7d4ac572fc583c734213879a15dde2140f6c3) | WordPress 2.1.0 | Implemented in [PR #47](https://github.com/emulsify-ds/emulsify-wordpress/pull/47); pending merge and release. | +| Drupal 7.3.0: [7.2.2](https://github.com/emulsify-ds/emulsify-drupal/tree/7.2.2) plus [PR #382](https://github.com/emulsify-ds/emulsify-drupal/pull/382), pinned at [`1bc8fb3`](https://github.com/emulsify-ds/emulsify-drupal/commit/1bc8fb3bb64ae7014f50349930216b863976a5b2) | WordPress 2.1.0 | Implemented in [PR #47](https://github.com/emulsify-ds/emulsify-wordpress/pull/47); pending merge and release. | This sync includes the 7.2.2 fixes underlying the Drupal 7.3.0 reference. The WordPress work is collected in one release branch by maintainer decision. @@ -105,6 +105,12 @@ implementation references, not a claim that either release has been published. | PHP/JavaScript Twig helper characterization | Port/adapt: a shared corpus records intentional helper differences and catches runtime bugs without silently changing contracts. | [PR #47](https://github.com/emulsify-ds/emulsify-wordpress/pull/47); [Twig authoring](timber-and-twig-authoring.md). | | Action pins, blocking readiness matrix, dependency maintenance, and README assets | Port/adapt: shared validation policy uses WordPress versions and the supported PHP matrix. | [PR #47](https://github.com/emulsify-ds/emulsify-wordpress/pull/47); [dependency audit](dependency-audit-2026-10-23.md). | | Privacy video embeds | Adapt, implemented for 2.1.0: render-time oEmbed filters, overridable iframe `srcdoc`, and cached background Vimeo metadata replace Drupal's same-origin embed document. Both options default to off. | [PR #47](https://github.com/emulsify-ds/emulsify-wordpress/pull/47); [media embeds](media-embeds.md). | +| Poster language and direction ([`dac2e49`](https://github.com/emulsify-ds/emulsify-drupal/commit/dac2e49), [`a2bcdc1`](https://github.com/emulsify-ds/emulsify-drupal/commit/a2bcdc1)) | Adapt: poster `lang` and `dir` follow the WordPress locale used for translated strings, with a non-default Arabic RTL regression and default-locale restoration. | [PR #47](https://github.com/emulsify-ds/emulsify-wordpress/pull/47); [poster override contract](media-embeds.md#poster-behavior-and-overrides). | + +The narrower settings help text in +[`dac2e49`](https://github.com/emulsify-ds/emulsify-drupal/commit/dac2e49) +needs no WordPress change: there is no media settings screen, and the +[media guide](media-embeds.md) already limits these options to YouTube and Vimeo. WordPress companion fixes in the same PR align grouped component discovery with Core, raise the minimum WordPress version to 6.8 to match locked Timber, and @@ -120,6 +126,10 @@ correct fallback language markup and failing link-color declarations. See the | Container and form template suggestions, inline form-error IDs, status-message/feed/progress template moves, and select option attributes | Skip: these belong to Drupal's render and Form APIs. | | Favicon dark-mode icons, SVG sanitization, and generated social share image | Skip: WordPress uses core Site Icon and `custom-logo`; SEO plugins own `og:image`. | | Default parent logo, library/breakpoint contract, Drupal.org channel-skew validation, Stable 9 parity, and the `dr` CLI | Skip: these are Drupal-only runtime and distribution contracts. | +| Favicon SVG processing fixes ([`6cb1101`](https://github.com/emulsify-ds/emulsify-drupal/commit/6cb1101), [`16ecce0`](https://github.com/emulsify-ds/emulsify-drupal/commit/16ecce0), [`4a10b74`](https://github.com/emulsify-ds/emulsify-drupal/commit/4a10b74)) | Skip: WordPress uses core Site Icon. | +| Favicon preview styling in dark admin themes ([`6b8b922`](https://github.com/emulsify-ds/emulsify-drupal/commit/6b8b922)) | Skip: WordPress has no favicon generator. | +| PostCSS development dependency for parent asset tests ([`88bf3d9`](https://github.com/emulsify-ds/emulsify-drupal/commit/88bf3d9)) | Skip: the WordPress parent has no asset build. | +| Wider strict-types check, favicon dark-mode test, and hook-contract documentation edits ([`1bc8fb3`](https://github.com/emulsify-ds/emulsify-drupal/commit/1bc8fb3bb64ae7014f50349930216b863976a5b2)) | Skip: these are Drupal-only conventions. | Although the parent ships no stylesheet, its `theme.json` produces WordPress global styles on the frontend: h1–h6 color and typography, visited-link color, From d234d8aaa2383d4c15de34b07488aa5e25b271e1 Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Fri, 23 Oct 2026 23:39:18 -0500 Subject: [PATCH 044/100] docs: finalize the 2.1.0 release notes --- docs/release-notes-next.md | 31 ++++++++++++++++++++----------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/docs/release-notes-next.md b/docs/release-notes-next.md index ca959c0..16d2c63 100644 --- a/docs/release-notes-next.md +++ b/docs/release-notes-next.md @@ -8,8 +8,8 @@ including the [Drupal 7.3.0 sync](sister-project-parity.md#sync-log). Latest published tag: `2.0.0`. This draft tracks the upcoming `2.1.0` release. -Root `package.json` remains at `2.0.0` until a release PR prepares `2.1.0` -together with every derived version surface. The version contract accepts the +The release PR prepares `2.1.0` in root `package.json` together with every +derived version surface. The version contract accepts the latest stable tag or an untagged next patch, minor, or major, and requires full Git history and tags. It rejects already-tagged draft versions and stale claims about the latest release. @@ -65,6 +65,9 @@ lineage. See the [release process](release-process.md). ## Fixed +- The media embed filter accepts missing post IDs and normalizes public-filter + inputs, including embeds rendered outside a post, without interrupting the + request. - Security: experimental mapped core-block rendering keeps parsed block data in `block_attributes` and starts HTML `attributes` as an empty array, preventing block comment data from becoming executable HTML attributes through Twig @@ -82,9 +85,14 @@ lineage. See the [release process](release-process.md). grouped discovery, including `.html.twig` and the `@project_machine_name/component` alias. Each child root is searched completely before parent roots, with deterministic duplicates and root containment. -- Twig helpers register Timber's safety/context options correctly. Argumentless - `bem()` and `add_attributes()` calls no longer consume unrelated context as - arguments or expose it as attributes. +- In `2.0.0`, Timber 2.5 passed Twig only a function's `callable` and `options` + keys, so the helpers' top-level `needs_context` and `is_safe` settings were + ignored. Every `bem()` and `add_attributes()` call printed escaped attribute + markup, such as `

        `, and never received context + attributes. The options are now registered correctly; `2.0.0` projects can + drop any `|raw` or other workaround they added. Argumentless `bem()` and + `add_attributes()` calls no longer consume unrelated context as arguments or + expose it as attributes. - The document shell preserves trusted WordPress language attributes as markup, producing a valid `lang` attribute. - Parent `theme.json` no longer sets the failing pale-blue normal link color or @@ -136,7 +144,7 @@ before opting in. See the required and optional actions in - [ ] Prepare `2.1.0` following the [release procedure](release-process.md): bump root `package.json`, both theme `Version` headers, `whisk/package.json`, and `whisk/project.emulsify.json` lineage together, including applicable lockfile - metadata. This documentation change does not bump versions. + metadata. - [ ] Re-sync [`emulsify-wordpress-starter`](https://github.com/emulsify-ds/emulsify-wordpress-starter) from `whisk/`, including hidden `.agents/` guidance and the standalone @@ -146,9 +154,10 @@ before opting in. See the required and optional actions in the npm registry on 2026-10-23 and requires Node.js 24.13.0 or newer. - [ ] Confirm the version resolved by a fresh generated theme lockfile still satisfies that range and the effective Node requirement. -- [ ] Run the readiness checks below on the prepared committed files, push the - release PR, and confirm semantic-release computes exactly `2.1.0` before - merging. Rotate this draft after publication. +- [ ] Run the readiness checks below on the prepared committed files and + confirm semantic-release computes exactly `2.1.0`. Have the maintainer review + and push the release PR, then confirm CI passes before merging. Rotate this + draft after publication. ## Validation notes @@ -159,5 +168,5 @@ before opting in. See the required and optional actions in WordPress fixture path; set `WP_SMOKE_REQUIRED=1` so missing prerequisites fail. - Run `npm run release:check -- --skip-smoke` only for static validation; it does not replace the full release gate. -- After pushing the release PR, run `npm run publish-test -- --no-ci` to verify - the prepared version matches semantic-release's calculation. +- Run `npm run publish-test -- --no-ci` to verify the prepared version matches + semantic-release's calculation; a local dry run can analyze unpushed commits. From 1c932664ad08b54f15d28698e5d11c99e421fd85 Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Fri, 23 Oct 2026 23:39:42 -0500 Subject: [PATCH 045/100] chore(release): prepare 2.1.0 --- package-lock.json | 4 ++-- package.json | 2 +- style.css | 2 +- whisk/package.json | 2 +- whisk/project.emulsify.json | 2 +- whisk/style.css | 2 +- 6 files changed, 7 insertions(+), 7 deletions(-) diff --git a/package-lock.json b/package-lock.json index 408b755..ba1e65e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "emulsify-wordpress", - "version": "2.0.0", + "version": "2.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "emulsify-wordpress", - "version": "2.0.0", + "version": "2.1.0", "license": "GPL-2.0-only", "devDependencies": { "@commitlint/cli": "^21.2.1", diff --git a/package.json b/package.json index 0c87d59..e2d862c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "emulsify-wordpress", - "version": "2.0.0", + "version": "2.1.0", "description": "Timber-first WordPress parent theme that generates child themes with Emulsify Core 4, Vite, Storybook, and Twig", "engines": { "node": ">=24.10" diff --git a/style.css b/style.css index d1509c6..d3e45bb 100644 --- a/style.css +++ b/style.css @@ -6,7 +6,7 @@ * Requires at least: 6.8 * Tested up to: 7.1 * Requires PHP: 8.3 - * Version: 2.0.0 + * Version: 2.1.0 * License: GPL-2.0-only * License URI: https://www.gnu.org/licenses/old-licenses/gpl-2.0.html */ diff --git a/whisk/package.json b/whisk/package.json index 2058b6e..b846480 100644 --- a/whisk/package.json +++ b/whisk/package.json @@ -1,6 +1,6 @@ { "name": "whisk", - "version": "2.0.0", + "version": "2.1.0", "description": "Generated WordPress child theme powered by Emulsify Core 4, Vite, Storybook, and Twig", "keywords": [ "component library", diff --git a/whisk/project.emulsify.json b/whisk/project.emulsify.json index 934de88..d33cce3 100644 --- a/whisk/project.emulsify.json +++ b/whisk/project.emulsify.json @@ -4,7 +4,7 @@ "name": "whisk", "machineName": "whisk", "generatedFrom": "emulsify-wordpress", - "generatedFromVersion": "2.0.0" + "generatedFromVersion": "2.1.0" }, "starter": { "repository": "https://github.com/emulsify-ds/emulsify-wordpress-starter" diff --git a/whisk/style.css b/whisk/style.css index 2c68d9e..7556ce6 100644 --- a/whisk/style.css +++ b/whisk/style.css @@ -4,7 +4,7 @@ * Template: emulsify * Description: A generated WordPress child theme powered by Emulsify Core 4, Vite, Storybook, and Twig. * Text Domain: whisk - * Version: 2.0.0 + * Version: 2.1.0 * License: GPL-2.0-only * License URI: https://www.gnu.org/licenses/old-licenses/gpl-2.0.html */ From 64aac7c6a6d009895c1de81873273608a9a5fab4 Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Sat, 24 Oct 2026 00:52:42 -0500 Subject: [PATCH 046/100] test(templates): verify child page lookup and restoration --- .github/scripts/template-extension-smoke.php | 24 +++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/.github/scripts/template-extension-smoke.php b/.github/scripts/template-extension-smoke.php index fe3b6cc..1a4cacb 100644 --- a/.github/scripts/template-extension-smoke.php +++ b/.github/scripts/template-extension-smoke.php @@ -207,6 +207,28 @@ static function ( $date, $format = null, $timezone = null ) use ( $core ): strin emulsify_extension_expect( false !== strpos( $rtl_output, '' ), 'Plain WordPress language attributes must preserve the language and RTL direction without escaping their quotes.', $errors ); emulsify_extension_expect( false !== strpos( $rtl_output, 'Fixture <site> & WordPress' ) && false !== strpos( $rtl_output, 'Page <title> & fixture' ), 'Trusting the core language attribute fragment must preserve autoescaping for site names and page titles.', $errors ); $page_expected = file_get_contents( __DIR__ . '/../fixtures/template-extension/page-post.html' ); + $child_template = get_stylesheet_directory() . '/templates/page.twig'; + $child_before = ''; + $child_after = ''; + $context = emulsify_extension_context( false, true, true ); + $context['post'] = array( 'id' => 42, 'type' => 'page', 'title' => 'Post & fixture', 'content' => $context['fixture_content'] ); + + try { + if ( false === file_put_contents( $child_template, '{% extends "@emulsify-tpl/page.twig" %}{% block content %}' . $child_before . '{{ parent() }}' . $child_after . '{% endblock %}' ) ) { + throw new RuntimeException( 'Could not write the disposable child page template.' ); + } + $output = $environment->render( '@templates/page.twig', $context ); + emulsify_extension_expect( 1 === substr_count( $output, $child_before ) && 1 === substr_count( $output, $child_after ), 'The child-first namespace must select the physical child page override exactly once.', $errors ); + emulsify_extension_expect( str_replace( array( $child_before, $child_after ), '', $output ) === $page_expected, 'The physical child page override must preserve every surrounding parent-page byte.', $errors ); + } finally { + if ( is_file( $child_template ) ) { + unlink( $child_template ); + } + // Refresh filesystem lookup caches after removing the temporary override. + $environment->setLoader( new \Twig\Loader\ChainLoader( array( $fixtures, $integration->loader_paths( new \Twig\Loader\FilesystemLoader() ) ) ) ); + } + emulsify_extension_expect( $environment->render( '@templates/page.twig', $context ) === $page_expected, 'Removing the physical child page override must restore every parent-page byte.', $errors ); + foreach ( array( 'head', 'header', 'content', 'footer', 'site_header', 'main', 'page_title' ) as $block ) { $name = 'child-page-' . $block . '.twig'; $marker = '<!-- child:' . $block . ' -->'; @@ -255,7 +277,7 @@ static function ( $date, $format = null, $timezone = null ) use ( $core ): strin if ( $errors ) { throw new RuntimeException( implode( "\n", $errors ) ); } - echo "PASS plain language attributes, RTL, other field autoescaping, child page inheritance, seven block names, replacement boundaries, nested blocks, and structural WordPress hooks.\n"; + echo "PASS plain language attributes, RTL, other field autoescaping, physical child page lookup and restoration, child page inheritance, seven block names, replacement boundaries, nested blocks, and structural WordPress hooks.\n"; } catch ( Throwable $throwable ) { fwrite( STDERR, $throwable->getMessage() . "\n" ); $exit_code = 1; From 037251f99338b381cad089b01214c282f0058b1e Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Wed, 28 Oct 2026 22:23:21 -0500 Subject: [PATCH 047/100] fix(templates): hide password-protected content and excerpts Enable Timber password handling before content is read, route protected pages through the existing password template, and guard both generic and post-specific teaser summaries. Use get_the_password_form() instead of reconstructing the form so WordPress owns the site URL, filtered markup, accessible fields and postpass redirects. Preserve the public content block and escape the core protected title. Keep empty filtered Timber forms truthy with an invisible placeholder: Timber otherwise falls through to protected content when a plugin hides the form. --- includes/Runtime/Context.php | 18 +++++++++++++++++- page.php | 10 ++++++++-- templates/partials/tease-post.twig | 2 +- templates/partials/tease.twig | 2 +- templates/single-password.twig | 30 ++++++------------------------ 5 files changed, 33 insertions(+), 29 deletions(-) diff --git a/includes/Runtime/Context.php b/includes/Runtime/Context.php index e90d59a..fd121a5 100644 --- a/includes/Runtime/Context.php +++ b/includes/Runtime/Context.php @@ -13,12 +13,28 @@ final class Context { /** - * Registers context filters. + * Registers context and protected-content filters. * * @return void */ public function register(): void { add_filter( 'timber/context', array( $this, 'add' ) ); + // Timber otherwise returns protected content instead of WordPress's form. + add_filter( 'timber/post/content/show_password_form_for_protected', '__return_true' ); + add_filter( 'timber/post/content/password_form', array( $this, 'protect_password_form' ), PHP_INT_MAX ); + } + + /** + * Keeps hidden password forms from exposing content through Timber. + * + * Timber treats an empty form as permission to continue to post content. + * A non-empty space preserves a plugin's hidden form without that fallback. + * + * @param mixed $form Filtered WordPress password form. + * @return string Form markup or an invisible, non-empty placeholder. + */ + public function protect_password_form( $form ): string { + return is_string( $form ) && $form ? $form : ' '; } /** diff --git a/page.php b/page.php index 8304c9b..f111a55 100644 --- a/page.php +++ b/page.php @@ -13,6 +13,12 @@ // Page rendering is intentionally thin: route data comes from Timber context, // while markup and extension points live in Twig. -$context = Timber::context(); +$context = Timber::context(); +$wp_post = $context['post'] ?? null; +$template = '@templates/page.twig'; -Timber::render( '@templates/page.twig', $context ); +if ( $wp_post && post_password_required( $wp_post->ID ) ) { + $template = '@templates/single-password.twig'; +} + +Timber::render( $template, $context ); diff --git a/templates/partials/tease-post.twig b/templates/partials/tease-post.twig index 4a88c99..181c68d 100644 --- a/templates/partials/tease-post.twig +++ b/templates/partials/tease-post.twig @@ -7,7 +7,7 @@ } %} {% block summary %} - {% if post.excerpt|default(null) %} + {% if not post.password_required and post.excerpt|default(null) %} <div {{ add_attributes(teaser_summary_attributes) }}> {{ post.excerpt({ diff --git a/templates/partials/tease.twig b/templates/partials/tease.twig index ff19254..d4e7971 100644 --- a/templates/partials/tease.twig +++ b/templates/partials/tease.twig @@ -30,7 +30,7 @@ {% endblock %} {% block summary %} - {% if post.excerpt|default(null) %} + {% if not post.password_required and post.excerpt|default(null) %} <div {{ add_attributes(teaser_summary_attributes) }}>{{ post.excerpt|raw }}</div> {% endif %} {% endblock %} diff --git a/templates/single-password.twig b/templates/single-password.twig index 424a6f2..591108d 100644 --- a/templates/single-password.twig +++ b/templates/single-password.twig @@ -1,32 +1,14 @@ -{# Password-protected content fallback. WordPress validates the password at - wp-login.php?action=postpass, matching core's standard flow. #} +{# Shared page/post password fallback. Core supplies the form, including its + site URL, accessible fields, redirect and the_password_form filter. #} {% extends '@templates/layouts/base.twig' %} -{% set password_form_attributes = { - class: bem('password-form'), - action: site.link|default('') ~ '/wp-login.php?action=postpass', - method: 'post' -} %} -{% set password_input_attributes = { - class: bem('input', [], 'password-form'), - name: 'post_password', - id: 'pwbox-' ~ (post.id|default('')), - type: 'password', - autocomplete: 'current-password' -} %} -{% set password_submit_attributes = { - class: bem('submit', [], 'password-form'), - type: 'submit', - name: 'Submit' -} %} +{% if post|default(null) %} + {% set title = function('get_the_title', post.id) %} +{% endif %} {% block content %} {% if post|default(null) %} - <form {{ add_attributes(password_form_attributes) }}> - <label for="pwbox-{{ post.id }}">{{ function('__', 'Password', 'emulsify') }}</label> - <input {{ add_attributes(password_input_attributes) }} /> - <button {{ add_attributes(password_submit_attributes) }}>{{ function('__', 'Submit', 'emulsify') }}</button> - </form> + {{ function('get_the_password_form', post.id)|raw }} {% else %} <p>{{ function('__', 'This content is password protected.', 'emulsify') }}</p> {% endif %} From 6c16bd480feaa44dcfaf9fd92de17d24711bb693 Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Wed, 28 Oct 2026 22:23:35 -0500 Subject: [PATCH 048/100] test(templates): cover password-protected pages, posts and listings Reproduce page and teaser leaks with real Twig and WordPress fixtures. Check protected titles, site/home URL separation, native form filters, static front pages, child content reads, empty forms and wrong/correct postpass cookies. Search uses a shared secret prefix because WordPress echoes the query publicly and already excludes protected posts from anonymous results. Assert protected teasers are actually present in the other listings. --- .github/scripts/template-extension-smoke.php | 54 ++++++- .github/scripts/wordpress-fixture-smoke.cjs | 158 ++++++++++++++++++- 2 files changed, 210 insertions(+), 2 deletions(-) diff --git a/.github/scripts/template-extension-smoke.php b/.github/scripts/template-extension-smoke.php index 1a4cacb..e3a18a9 100644 --- a/.github/scripts/template-extension-smoke.php +++ b/.github/scripts/template-extension-smoke.php @@ -42,6 +42,16 @@ function __( string $text, string $domain = 'default' ): string { return $text; } +function get_the_password_form( $post = 0 ): string { + $GLOBALS['emulsify_extension_password_form_posts'][] = $post; + return '<form class="post-password-form" data-filtered-password-form="true" action="https://example.test/wordpress/wp-login.php?action=postpass" method="post"><label for="pwbox-' . $post . '">Password</label><input id="pwbox-' . $post . '" name="post_password" type="password"><button type="submit">Enter</button></form>'; +} + +function get_the_title( $post = 0 ): string { + $GLOBALS['emulsify_extension_title_posts'][] = $post; + return 'Protected: Secret <title> & fixture'; +} + function do_action( string $hook, ...$arguments ): void { $GLOBALS['emulsify_extension_hooks'][] = $hook; echo '<!-- action:' . $hook . ' -->'; @@ -201,6 +211,48 @@ static function ( $date, $format = null, $timezone = null ) use ( $core ): strin } $errors = array(); + foreach ( array( 'tease.twig', 'tease-post.twig' ) as $teaser_template ) { + foreach ( array( 'manual', 'automatic' ) as $excerpt_source ) { + foreach ( array( false, true ) as $protected ) { + $secret = 'protected-' . $excerpt_source . '-excerpt-secret'; + $post = new class( $protected, $secret ) { + public $password_required; + public $excerpt_reads = 0; + private $summary; + + public function __construct( bool $protected, string $summary ) { + $this->password_required = $protected; + $this->summary = $summary; + } + + public function excerpt( array $options = array() ): string { + ++$this->excerpt_reads; + return $this->summary; + } + }; + $teaser = $environment->render( '@emulsify-tpl/partials/' . $teaser_template, array( 'post' => $post ) ); + emulsify_extension_expect( $protected ? false === strpos( $teaser, $secret ) && 0 === $post->excerpt_reads : false !== strpos( $teaser, $secret ) && $post->excerpt_reads > 0, $teaser_template . ' must skip ' . $excerpt_source . ' excerpts entirely while a password is required and retain them after unlock.', $errors ); + } + } + } + + $password_context = emulsify_extension_context( false, true, false ); + $password_context['site']['link'] = 'https://example.test'; + $password_context['post'] = array( 'id' => 73, 'password_required' => true, 'content' => 'protected-content-secret', 'excerpt' => 'protected-excerpt-secret' ); + $GLOBALS['emulsify_extension_password_form_posts'] = array(); + $GLOBALS['emulsify_extension_title_posts'] = array(); + $GLOBALS['emulsify_extension_hooks'] = array(); + $password_output = $environment->render( '@emulsify-tpl/single-password.twig', $password_context ); + emulsify_extension_expect( array( 73 ) === $GLOBALS['emulsify_extension_password_form_posts'], 'The password template must request WordPress\'s form for the current post ID.', $errors ); + emulsify_extension_expect( array( 73 ) === $GLOBALS['emulsify_extension_title_posts'] && false !== strpos( $password_output, '>Protected: Secret <title> & fixture</h1>' ), 'The password template must display WordPress\'s protected title while preserving title autoescaping.', $errors ); + emulsify_extension_expect( false !== strpos( $password_output, '<form class="post-password-form" data-filtered-password-form="true" action="https://example.test/wordpress/wp-login.php?action=postpass"' ) && false === strpos( $password_output, '<form'), 'The password template must preserve trusted core/plugin form markup and the site URL instead of building a home-URL action.', $errors ); + emulsify_extension_expect( false === strpos( $password_output, 'protected-content-secret' ) && false === strpos( $password_output, 'protected-excerpt-secret' ), 'The password template must never print protected content or excerpts.', $errors ); + emulsify_extension_expect( array( 'get_header', 'wp_head', 'wp_body_open', 'wp_footer', 'get_footer' ) === $GLOBALS['emulsify_extension_hooks'], 'The password template must preserve structural WordPress hooks and their order.', $errors ); + $password_marker = '<!-- child password content -->'; + $fixtures->setTemplate( 'child-password-content.twig', '{% extends "@emulsify-tpl/single-password.twig" %}{% block content %}' . $password_marker . '{{ parent() }}{% endblock %}' ); + $password_child = $environment->render( 'child-password-content.twig', $password_context ); + emulsify_extension_expect( 1 === substr_count( $password_child, $password_marker ) && str_replace( $password_marker, '', $password_child ) === $password_output, 'Child password templates must retain the public content block and surrounding parent markup.', $errors ); + $rtl_context = emulsify_extension_context( true, true, true ); $rtl_context['site']['language_attributes'] = 'lang="ar" dir="rtl"'; $rtl_output = $environment->render( 'content-fixture.twig', $rtl_context ); @@ -277,7 +329,7 @@ static function ( $date, $format = null, $timezone = null ) use ( $core ): strin if ( $errors ) { throw new RuntimeException( implode( "\n", $errors ) ); } - echo "PASS plain language attributes, RTL, other field autoescaping, physical child page lookup and restoration, child page inheritance, seven block names, replacement boundaries, nested blocks, and structural WordPress hooks.\n"; + echo "PASS protected teaser excerpts, native password forms and child inheritance, plain language attributes, RTL, other field autoescaping, physical child page lookup and restoration, child page inheritance, seven block names, replacement boundaries, nested blocks, and structural WordPress hooks.\n"; } catch ( Throwable $throwable ) { fwrite( STDERR, $throwable->getMessage() . "\n" ); $exit_code = 1; diff --git a/.github/scripts/wordpress-fixture-smoke.cjs b/.github/scripts/wordpress-fixture-smoke.cjs index c98ede7..8a69c68 100755 --- a/.github/scripts/wordpress-fixture-smoke.cjs +++ b/.github/scripts/wordpress-fixture-smoke.cjs @@ -733,7 +733,7 @@ async function waitForServer(baseUrl) { } async function checkRoute(baseUrl, route) { - const response = await fetch(`${baseUrl}${route.path}`); + const response = await fetch(`${baseUrl}${route.path}`, { headers: route.headers }); const body = await response.text(); const expectedStatus = route.status || 200; @@ -862,6 +862,161 @@ async function checkChildBlockExtension(baseUrl, pagePath, themePath, baseline) } } +async function checkPasswordProtection(wpPath, content, baseUrl, themePath) { + const password = 'fixture-content-password'; + const secrets = ['PasswordFixtureProbePageContent', 'PasswordFixtureProbePageExcerpt', 'PasswordFixtureProbePostContent', 'PasswordFixtureProbePostExcerpt']; + const created = []; + const originalSiteUrl = wp(wpPath, ['option', 'get', 'siteurl']); + const originalFront = wp(wpPath, ['option', 'get', 'show_on_front']); + const originalFrontPage = wp(wpPath, ['option', 'get', 'page_on_front']); + const childPasswordTemplate = path.join(themePath, 'templates', 'single-password.twig'); + const originalPasswordTemplate = fs.existsSync(childPasswordTemplate) ? fs.readFileSync(childPasswordTemplate, 'utf8') : null; + const formPlugin = path.join(wpPath, 'wp-content', 'mu-plugins', 'emulsify-password-fixture.php'); + const assertPrivate = (body, name) => { + for (const secret of secrets) { + if (body.includes(secret)) throw new Error(`${name} leaked protected content or an excerpt: ${secret}.`); + } + }; + const assertForm = (body, expectedAction, id, name) => { + const forms = [...body.matchAll(/<form\b[^>]*\baction=(["'])(.*?)\1[^>]*>/gi)]; + if (!forms.some((form) => form[2].replace(/&(?:amp|#0*38|#x0*26);/gi, '&') === expectedAction)) { + throw new Error(`${name} must post its password form to ${expectedAction}.`); + } + if (!body.includes('data-password-fixture="core"') || !body.includes(`for="pwbox-${id}"`) || !body.includes('name="post_password"')) { + throw new Error(`${name} must use the filterable WordPress form and its labeled password field.`); + } + }; + const request = (name, route, headers) => checkRoute(baseUrl, { name, path: route, includes: [], headers }); + const submitPassword = async (id, value) => { + const response = await fetch(`${baseUrl}/wp-login.php?action=postpass`, { + method: 'POST', redirect: 'manual', + body: new URLSearchParams({ post_password: value, redirect_to: `${baseUrl}/?p=${id}` }), + }); + const cookie = response.headers.getSetCookie().map((value) => value.split(';')[0]).find((value) => value.startsWith('wp-postpass_')); + if (response.status !== 302 || !cookie) throw new Error('WordPress postpass must redirect and issue its real password cookie.'); + return { Cookie: cookie }; + }; + + try { + for (const [index, type] of ['page', 'post'].entries()) { + created.push(wp(wpPath, [ + 'post', 'create', `--post_type=${type}`, '--post_status=publish', + `--post_title=Password fixture ${type}`, `--post_content=${secrets[index * 2]}`, + `--post_excerpt=${secrets[index * 2 + 1]}`, `--post_password=${password}`, + `--post_author=${content.authorId}`, '--porcelain', + ])); + } + const [pageId, postId] = created; + wp(wpPath, ['post', 'term', 'add', postId, 'category', content.categoryId, '--by=id']); + fs.writeFileSync(formPlugin, `<?php +add_filter( 'the_password_form', function ( $form ) { + if ( 'the_password_form' === get_option( 'emulsify_fixture_empty_password_form' ) ) { + return ''; + } + return str_replace( '<form ', '<form data-password-fixture="core" ', $form ); +}, 999 ); +add_filter( 'timber/post/content/password_form', function ( $form ) { + return 'timber/post/content/password_form' === get_option( 'emulsify_fixture_empty_password_form' ) ? '' : $form; +}, 999 ); +`); + const routes = [ + ['protected page', `/?page_id=${pageId}`, pageId], + ['protected post', `/?p=${postId}`, postId], + ['protected blog listing', '/'], + ['protected category listing', `/?cat=${content.categoryId}`], + ['protected author listing', `/?author=${content.authorId}`], + // Core excludes protected posts from anonymous search. Search for the + // shared prefix so the public heading cannot echo a complete secret. + ['protected search listing', '/?s=PasswordFixtureProbe'], + ]; + for (const [name, route, id] of routes) { + const body = await request(name, route); + assertPrivate(body, name); + if (!id && !route.startsWith('/?s=') && !body.includes(`id="teaser-${postId}"`)) { + throw new Error(`${name} must include the protected post's teaser to exercise excerpt suppression.`); + } + if (id) { + assertForm(body, `${originalSiteUrl}/wp-login.php?action=postpass`, id, name); + const title = wp(wpPath, ['eval', `echo get_the_title( ${Number(id)} );`]); + if (!body.includes(title)) throw new Error(`${name} must retain WordPress's protected title: ${title}.`); + } + } + + // A site installed below its public home URL must use site_url(), and + // customizations of the core form must reach both singular routes. + wp(wpPath, ['option', 'update', 'siteurl', `${baseUrl}/wordpress-core`]); + try { + for (const [name, route, id] of routes.slice(0, 2)) { + const body = await request(`${name} with separate site URL`, route); + assertPrivate(body, name); + assertForm(body, `${baseUrl}/wordpress-core/wp-login.php?action=postpass`, id, name); + } + } finally { + wp(wpPath, ['option', 'update', 'siteurl', originalSiteUrl]); + } + + wp(wpPath, ['option', 'update', 'show_on_front', 'page']); + wp(wpPath, ['option', 'update', 'page_on_front', pageId]); + try { + const front = await request('protected static front page', '/'); + assertPrivate(front, 'protected static front page'); + assertForm(front, `${originalSiteUrl}/wp-login.php?action=postpass`, pageId, 'protected static front page'); + } finally { + wp(wpPath, ['option', 'update', 'show_on_front', originalFront]); + wp(wpPath, ['option', 'update', 'page_on_front', originalFrontPage]); + } + + // The shared Timber filter must also protect a child that directly prints + // post.content rather than inheriting the parent's password form markup. + fs.mkdirSync(path.dirname(childPasswordTemplate), { recursive: true }); + fs.writeFileSync(childPasswordTemplate, `{% extends '@emulsify-tpl/layouts/base.twig' %}{% block content %}<div data-password-child-content>{{ post.content|raw }}</div>{% endblock %}`); + for (const [name, route, id] of routes.slice(0, 2)) { + const body = await request(`${name} through child post.content`, route); + assertPrivate(body, name); + assertForm(body, `${originalSiteUrl}/wp-login.php?action=postpass`, id, name); + } + for (const hook of ['the_password_form', 'timber/post/content/password_form']) { + wp(wpPath, ['option', 'update', 'emulsify_fixture_empty_password_form', hook]); + for (const [name, route] of routes.slice(0, 2)) { + const body = await request(`${name} with empty ${hook}`, route); + assertPrivate(body, `${name} with empty ${hook}`); + if (!body.includes('data-password-child-content') || body.includes('name="post_password"')) { + throw new Error('A hidden password form must remain hidden without exposing child post.content.'); + } + } + } + wp(wpPath, ['option', 'delete', 'emulsify_fixture_empty_password_form']); + if (originalPasswordTemplate === null) fs.rmSync(childPasswordTemplate); + else fs.writeFileSync(childPasswordTemplate, originalPasswordTemplate); + + const wrongCookie = await submitPassword(postId, 'incorrect-fixture-password'); + for (const [name, route, id] of routes.slice(0, 2)) { + const body = await request(`${name} with incorrect password`, route, wrongCookie); + assertPrivate(body, name); + assertForm(body, `${originalSiteUrl}/wp-login.php?action=postpass`, id, name); + } + for (const [index, [name, route, id]] of routes.slice(0, 2).entries()) { + const correctCookie = await submitPassword(id, password); + const body = await request(`${name} after postpass`, route, correctCookie); + if (!body.includes(secrets[index * 2]) || body.includes('name="post_password"')) { + throw new Error(`${name} must reveal its content after the correct WordPress password cookie.`); + } + } + await checkRoute(baseUrl, { name: 'unprotected page after password checks', path: `/?page_id=${content.pageId}`, includes: ['Smoke page content'] }); + await checkRoute(baseUrl, { name: 'unprotected post after password checks', path: `/?p=${content.postId}`, includes: ['Smoke post content'] }); + log('Password protection: page/post/listing secrets stayed private, native forms used site URL and filters, static front pages and direct child content stayed protected, and wrong/correct postpass cookies behaved correctly.'); + } finally { + wp(wpPath, ['eval', "delete_option( 'emulsify_fixture_empty_password_form' );"]); + wp(wpPath, ['option', 'update', 'siteurl', originalSiteUrl]); + wp(wpPath, ['option', 'update', 'show_on_front', originalFront]); + wp(wpPath, ['option', 'update', 'page_on_front', originalFrontPage]); + if (originalPasswordTemplate === null) fs.rmSync(childPasswordTemplate, { force: true }); + else fs.writeFileSync(childPasswordTemplate, originalPasswordTemplate); + fs.rmSync(formPlugin, { force: true }); + for (const id of created) wp(wpPath, ['post', 'delete', id, '--force']); + } +} + async function renderRoutes(wpPath, content, baseUrl, port, themeSlug) { startServer(wpPath, port); await waitForServer(baseUrl); @@ -912,6 +1067,7 @@ async function renderRoutes(wpPath, content, baseUrl, port, themeSlug) { ); await checkSearchPagination(baseUrl); await checkGeneratedAssets(baseUrl, themeSlug); + await checkPasswordProtection(wpPath, content, baseUrl, path.join(wpPath, 'wp-content', 'themes', themeSlug)); if (auditAccessibility) { const archiveCount = Number(wp(wpPath, ['post', 'list', '--post_type=post', '--post_status=publish', `--cat=${content.categoryId}`, '--format=count'])); From e605d8d3e01fe7d1c675c76a2b67d8c81649ed55 Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Wed, 28 Oct 2026 22:23:51 -0500 Subject: [PATCH 049/100] docs: document password-protected content handling --- UPGRADE.md | 10 ++++++++++ docs/release-notes-next.md | 5 +++++ docs/template-extension.md | 25 +++++++++++++++++++++++-- 3 files changed, 38 insertions(+), 2 deletions(-) diff --git a/UPGRADE.md b/UPGRADE.md index b7cb802..8ee2a67 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -80,6 +80,16 @@ remain project-owned. New generated themes receive the updated defaults. copied anchor would duplicate its ID. The PHP attribute bag also rejects case-insensitive `on*` names to prevent event-handler attributes. See [core-block context](docs/core-block-twig-rendering.md#twig-context). +6. **Review password-protected content in child overrides.** Check + `post.password_required` in copied `page.twig`, `single.twig`, + `partials/tease.twig`, and `partials/tease-post.twig` before printing protected + content or excerpts. The parent now enables Timber's password-form handling + for `post.content`, including child templates; `post.excerpt`, raw + `post.post_content`, and custom rendering still need an explicit guard. + Protected pages and posts share `single-password.twig`, which now uses + WordPress's filtered password form and site URL. Review styles targeting the + former custom form; core uses `.post-password-form`. See + [password-protected template handling](docs/template-extension.md#password-protected-content). Render representative pages as both an administrator and an anonymous visitor, check the editor where blocks are used, and inspect `debug.log`. Storybook diff --git a/docs/release-notes-next.md b/docs/release-notes-next.md index 16d2c63..1c8dcda 100644 --- a/docs/release-notes-next.md +++ b/docs/release-notes-next.md @@ -65,6 +65,11 @@ lineage. See the [release process](release-process.md). ## Fixed +- Security: password-protected pages and listing excerpts no longer expose + content without a valid post-password cookie, fixing an issue present in + `2.0.0`. Timber content reads now show the password form, and protected pages + and posts use WordPress's filtered form with the correct site URL, including + installations in a subdirectory. - The media embed filter accepts missing post IDs and normalizes public-filter inputs, including embeds rendered outside a post, without interrupting the request. diff --git a/docs/template-extension.md b/docs/template-extension.md index fc14119..00df74c 100644 --- a/docs/template-extension.md +++ b/docs/template-extension.md @@ -18,7 +18,8 @@ In a child `templates/page.twig`, extend the parent-only namespace: {% endblock %} ``` -WordPress selects the parent's `page.php`, which renders `@templates/page.twig`. +For pages that do not require a password, WordPress selects the parent's +`page.php`, which renders `@templates/page.twig`. That child-first lookup selects the child's override. Its `@emulsify-tpl` extends then selects the parent explicitly, so surrounding layout and later parent fixes continue to reach the child. `parent()` preserves the selected block's default @@ -47,7 +48,7 @@ blocks retain their original names and boundaries. | `layouts/base.twig` | `footer` | The footer partial and copyright markup | | `page.twig` | `content` | Page article with title/content, or the empty-page message | | `single.twig` | `content` | Post article, metadata, content, and comments | -| `single-password.twig` | `content` | Password-protected post form | +| `single-password.twig` | `content` | WordPress password form for protected posts and pages | | `index.twig`, `archive.twig`, `author.twig`, `search.twig` | `content` | Teasers, pagination, or the route's empty-result message | | `404.twig` | `content` | Not-found heading and message | @@ -61,6 +62,26 @@ The base layout keeps `wp_body_open()`, the skip link, `wp_footer()`, and the `get_footer` action outside every overridable block. Block overrides therefore preserve these WordPress hooks and the skip link. +## Password-protected content + +When `post_password_required()` is true, the parent page and single controllers +render `@templates/single-password.twig`. This remains a child-first extension +point for both posts and pages. A same-path child override can extend +`@emulsify-tpl/single-password.twig` and call `parent()` in its `content` block. + +The parent fallback sets its title with `get_the_title()` and prints +`get_the_password_form( post.id )`, preserving WordPress's protected-title +format, site URL, password-cookie flow, and `the_password_form` filter. Use that +WordPress filter to customize the form; core's form uses `.post-password-form` +rather than the former custom `.password-form` markup. + +The parent enables Timber's password-form handling for `post.content` even in +child templates. Empty filtered forms stay hidden without falling through to +protected content. Excerpts and raw `post.post_content` do not have that +protection. Check `post.password_required` before rendering them, including in +overrides of `partials/tease.twig` and `partials/tease-post.twig`. A correct +post-password cookie makes that check false and restores normal rendering. + ## Verification Run `npm run smoke:template-extension` after `composer install`. It compares From e9d6295141d96012dceb292616c3326a8837526c Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Wed, 28 Oct 2026 23:26:22 -0500 Subject: [PATCH 050/100] ci: keep the contributors bot from committing to main Remove the contributors workflow and its write-permission exception. Maintain the README contributors list through ordinary reviewed pull requests instead. The pinned action updates its target branch before optionally opening a PR, requires separate branch management, and upgrades packaging tools at runtime. Keeping scheduled runs would retain the release race and privileged token. --- .github/scripts/release-check.cjs | 5 ----- .github/workflows/contributors.yml | 31 ------------------------------ 2 files changed, 36 deletions(-) delete mode 100644 .github/workflows/contributors.yml diff --git a/.github/scripts/release-check.cjs b/.github/scripts/release-check.cjs index 354ba82..d39ab84 100644 --- a/.github/scripts/release-check.cjs +++ b/.github/scripts/release-check.cjs @@ -1497,16 +1497,11 @@ function runStaticChecks() { ensure(!Object.hasOwn(workflow.on || {}, 'pull_request_target'), `${file} must not run untrusted pull requests with pull_request_target credentials.`); for (const [jobName, job] of Object.entries(workflow.jobs || {})) { const publishingJob = file === '.github/workflows/semantic-release.yml' && jobName === 'semantic-release'; - const contributorsJob = file === '.github/workflows/contributors.yml' && jobName === 'add-contributors'; if (publishingJob) { ensure(job.permissions?.contents === 'write' && job.permissions?.issues === 'write' && job.permissions?.['pull-requests'] === 'write' && Object.keys(job.permissions).length === 3, 'The semantic-release publishing job must request exactly contents, issues, and pull-requests write permissions.'); } - else if (contributorsJob) { - ensure(job.permissions?.contents === 'write' && Object.keys(job.permissions).length === 1, - 'Contributor automation must request only contents: write.'); - } else { ensure(job.permissions === undefined || (typeof job.permissions === 'object' && Object.values(job.permissions).every((value) => value === 'read' || value === 'none')), diff --git a/.github/workflows/contributors.yml b/.github/workflows/contributors.yml deleted file mode 100644 index 57024eb..0000000 --- a/.github/workflows/contributors.yml +++ /dev/null @@ -1,31 +0,0 @@ -name: Add contributors -on: - schedule: - - cron: '20 20 * * *' - push: - branches: - - main - workflow_dispatch: - -permissions: - contents: read - -jobs: - add-contributors: - runs-on: ubuntu-latest - permissions: - contents: write - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - uses: BobAnkh/add-contributors@de6c43a0c154b093a210d8bdf7ab283aba8452dd # v0.2.2 - with: - CONTRIBUTOR: '### Contributors' - COLUMN_PER_ROW: '6' - ACCESS_TOKEN: ${{ secrets.GITHUB_TOKEN }} - IMG_WIDTH: '100' - FONT_SIZE: '14' - PATH: '/README.md' - COMMIT_MESSAGE: 'docs(README): update contributors' - AVATAR_SHAPE: 'round' From a4b2546be0f8bc9638f8e9ff4541a5109d2528bb Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Wed, 28 Oct 2026 23:26:57 -0500 Subject: [PATCH 051/100] fix(release): explain a release that main moved under Compare CI HEAD with live origin/main before invoking semantic-release, even when --no-ci is used for the release dry run. Keep unprepared-release skips and local release-branch dry runs unchanged. Accept a matching detached checkout and keep the configured main branch in CI. If main moved, direct maintainers to a new workflow_dispatch run on main. WordPress Release is the workflow name; Semantic release is its publishing job. Scratch-remote tests reproduce stale tracking refs and verify fresh dispatch recovery, CI flag handling, and local dry-run behavior before publishing. --- .github/scripts/semantic-release.cjs | 24 ++++++- .github/scripts/semantic-release.test.cjs | 76 +++++++++++++++++++++-- 2 files changed, 94 insertions(+), 6 deletions(-) diff --git a/.github/scripts/semantic-release.cjs b/.github/scripts/semantic-release.cjs index 545d2cc..bf8565d 100644 --- a/.github/scripts/semantic-release.cjs +++ b/.github/scripts/semantic-release.cjs @@ -24,6 +24,24 @@ function assertPublishedStableTags(repoRoot) { } } +function assertCurrentMain(repoRoot) { + const head = git(repoRoot, ['rev-parse', 'HEAD']); + let remoteMain; + try { + remoteMain = git(repoRoot, ['ls-remote', 'origin', 'refs/heads/main']).split(/\s+/)[0]; + } catch (error) { + throw new Error(`Unable to verify origin/main before releasing; check origin connectivity and retry. ${error.message}`); + } + if (!remoteMain) { + throw new Error('Unable to verify origin/main before releasing: origin has no refs/heads/main.'); + } + if (head !== remoteMain) { + throw new Error( + `Main moved during the release: HEAD ${head} differs from origin/main ${remoteMain}. The Semantic release job is stale. Start a new workflow_dispatch run of WordPress Release (semantic-release.yml) on main instead of re-running the old job.`, + ); + } +} + function releaseOptions(args) { const { values } = parseArgs({ args, @@ -54,8 +72,10 @@ async function main({ repoRoot = path.resolve(__dirname, '../..'), args = process.argv.slice(2), runRelease = runSemanticRelease, + env = process.env, } = {}) { const options = releaseOptions(args); + const isCI = Boolean(env.CI) && env.CI !== 'false'; const contract = validateReleaseVersionContract({ repoRoot }); if (contract.errors.length > 0) { throw new Error(contract.errors.join('\n')); @@ -69,9 +89,11 @@ async function main({ return 0; } + if (isCI) assertCurrentMain(repoRoot); + // A release PR can verify its version before merging to main. This override // is only for an explicit local dry run, which cannot tag or publish. - if (options.dryRun && options.ci === false) { + if (!isCI && options.dryRun && options.ci === false) { options.branches = [git(repoRoot, ['branch', '--show-current'])]; } diff --git a/.github/scripts/semantic-release.test.cjs b/.github/scripts/semantic-release.test.cjs index 10a0c9c..fd76119 100644 --- a/.github/scripts/semantic-release.test.cjs +++ b/.github/scripts/semantic-release.test.cjs @@ -40,13 +40,13 @@ test('publish guard accepts the exact prepared version on every subsequent relea test('skips publishing successfully when package.json equals the latest tag', async (t) => { const { repoRoot } = fixture(t, '2.0.0'); - const status = await main({ repoRoot, args: ['--dry-run', '--debug', '--no-ci'], runRelease: () => assert.fail('An unprepared release must not invoke semantic-release.') }); + const status = await main({ repoRoot, env: {}, args: ['--dry-run', '--debug', '--no-ci'], runRelease: () => assert.fail('An unprepared release must not invoke semantic-release.') }); assert.equal(status, 0); }); test('prepared release accepts a matching semantic-release result and forwards documented flags', async (t) => { const { repoRoot } = fixture(t); - const status = await main({ repoRoot, args: ['--tag-format', '${version}', '--dry-run', '--debug', '--no-ci'], runRelease: async (options, context) => { + const status = await main({ repoRoot, env: {}, args: ['--tag-format', '${version}', '--dry-run', '--debug', '--no-ci'], runRelease: async (options, context) => { assert.equal(context.cwd, repoRoot); assert.equal(options.tagFormat, '${version}'); assert.equal(options.dryRun, true); @@ -61,7 +61,7 @@ test('prepared release accepts a matching semantic-release result and forwards d test('prepared release rejects mismatched or absent semantic-release results', async (t) => { const { repoRoot } = fixture(t); for (const result of [false, { nextRelease: { version: '2.0.1' } }]) { - await assert.rejects(main({ repoRoot, args: [], runRelease: async () => result }), /Prepared package.json version 2\.1\.0.*(?:computed 2\.0\.1|did not compute a release).*Conventional Commits/); + await assert.rejects(main({ repoRoot, env: {}, args: [], runRelease: async () => result }), /Prepared package.json version 2\.1\.0.*(?:computed 2\.0\.1|did not compute a release).*Conventional Commits/); } }); @@ -70,7 +70,7 @@ test('refuses local-only stable tags before any publishing or skip', async (t) = assert.doesNotThrow(() => assertPublishedStableTags(repoRoot)); git('tag', '2.0.1'); assert.throws(() => assertPublishedStableTags(repoRoot), /Refusing to publish local-only stable tag\(s\): 2\.0\.1/); - await assert.rejects(main({ repoRoot, args: [], runRelease: () => assert.fail('Local-only stable tags must block semantic-release.') }), /Refusing to publish local-only stable tag\(s\): 2\.0\.1/); + await assert.rejects(main({ repoRoot, env: {}, args: [], runRelease: () => assert.fail('Local-only stable tags must block semantic-release.') }), /Refusing to publish local-only stable tag\(s\): 2\.0\.1/); }); test('also refuses local-only stable tags on unmerged branches', (t) => { @@ -83,6 +83,72 @@ test('also refuses local-only stable tags on unmerged branches', (t) => { assert.throws(() => assertPublishedStableTags(repoRoot), /Refusing to publish local-only stable tag\(s\): 2\.0\.1/); }); +function advanceRemoteMain({ repoRoot, remote }) { + const otherCheckout = path.join(path.dirname(repoRoot), 'main-mover'); + execFileSync('git', ['clone', '--quiet', remote, otherCheckout], { stdio: 'pipe' }); + const git = (...args) => execFileSync('git', args, { cwd: otherCheckout, encoding: 'utf8', stdio: 'pipe' }).trim(); + git('-c', 'user.name=Contract Test', '-c', 'user.email=contract@example.invalid', 'commit', '--allow-empty', '-m', 'docs: update contributors'); + git('push', 'origin', 'main'); + return git('rev-parse', 'HEAD'); +} + +test('CI refuses a moved remote main before semantic-release, even with --no-ci and a stale tracking ref', async (t) => { + const repository = fixture(t); + const { repoRoot, git } = repository; + const checkedOut = git('rev-parse', 'HEAD'); + const remoteHead = advanceRemoteMain(repository); + assert.notEqual(remoteHead, checkedOut); + assert.equal(git('rev-parse', 'origin/main'), checkedOut, 'The regression must not refresh the stale tracking ref.'); + let released = false; + await assert.rejects(main({ + repoRoot, env: { CI: 'true' }, args: ['--dry-run', '--no-ci'], + runRelease: async () => { released = true; return { nextRelease: { version: '2.1.0' } }; }, + }), /main moved.*Semantic release.*workflow_dispatch.*main/i); + assert.equal(released, false, 'The preflight must stop before semantic-release can run.'); +}); + +test('CI accepts current main and a fresh detached workflow_dispatch after main moved', async (t) => { + const repository = fixture(t); + const { repoRoot, git } = repository; + const runRelease = async (options) => { + assert.equal(options.branches, undefined, 'CI must keep the configured main branch even with --no-ci.'); + return { nextRelease: { version: '2.1.0' } }; + }; + assert.equal(await main({ repoRoot, env: { CI: 'true' }, args: [], runRelease }), 0); + const currentMain = advanceRemoteMain(repository); + git('fetch', 'origin', 'main'); + git('switch', '--detach', 'origin/main'); + assert.equal(git('rev-parse', 'HEAD'), currentMain); + assert.equal(await main({ + repoRoot, env: { CI: 'true', GITHUB_EVENT_NAME: 'workflow_dispatch', GITHUB_REF: 'refs/heads/main' }, + args: ['--dry-run', '--no-ci'], runRelease, + }), 0); +}); + +test('local dry runs on a release branch do not require HEAD to equal remote main', async (t) => { + const { repoRoot, git } = fixture(t); + git('switch', '-c', 'release-2.1.x'); + git('-c', 'user.name=Contract Test', '-c', 'user.email=contract@example.invalid', 'commit', '--allow-empty', '-m', 'feat: prepare the next release'); + for (const env of [{}, { CI: 'false' }]) { + assert.equal(await main({ + repoRoot, env, args: ['--dry-run', '--no-ci'], + runRelease: async (options) => { + assert.deepEqual(options.branches, ['release-2.1.x']); + return { nextRelease: { version: '2.1.0' } }; + }, + }), 0); + } +}); + +test('CI still skips a valid unprepared release without invoking semantic-release', async (t) => { + const repository = fixture(t, '2.0.0'); + advanceRemoteMain(repository); + assert.equal(await main({ + repoRoot: repository.repoRoot, env: { CI: 'true' }, args: [], + runRelease: () => assert.fail('An unprepared release must not invoke semantic-release.'), + }), 0); +}); + test('parses the existing publish and publish-test command options', () => { assert.deepEqual(releaseOptions(['--tag-format', '${version}', '--dry-run', '--debug', '--no-ci']), { tagFormat: '${version}', dryRun: true, debug: true, ci: false }); assert.deepEqual(releaseOptions(['-t', '${version}', '-d']), { tagFormat: '${version}', dryRun: true }); @@ -106,7 +172,7 @@ test('real semantic-release dry run analyzes a pushed release PR branch and bloc const runRelease = (options, context) => semanticRelease({ ...config, ...options, repositoryUrl: ${JSON.stringify(remote)}, plugins: [config.plugins[0], guard], }, context); - const options = { repoRoot: process.cwd(), args: ['--dry-run', '--no-ci'], runRelease }; + const options = { repoRoot: process.cwd(), env: {}, args: ['--dry-run', '--no-ci'], runRelease }; assert.equal(await main(options), 0); fs.writeFileSync('package.json', JSON.stringify({ version: '2.0.1' })); await assert.rejects(main(options), /Prepared package.json version 2\\.0\\.1.*computed 2\\.1\\.0/); From 732435365650d030ce5956b0c715a7a2486ac4dd Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Wed, 28 Oct 2026 23:27:21 -0500 Subject: [PATCH 052/100] docs: document the release run's main-branch requirement --- docs/release-process.md | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/docs/release-process.md b/docs/release-process.md index 3165bf6..24fb608 100644 --- a/docs/release-process.md +++ b/docs/release-process.md @@ -76,6 +76,21 @@ match the latest stable tag. heading and release title, and update or remove its latest-tag claim. The contract rejects the shipped draft on subsequent runs until it is rotated. +Keep `main` at the merge commit until the release finishes. Do not merge or +push other changes while release readiness and publishing are running. +Contributor updates are maintained by hand in reviewed pull requests; the +contributors bot workflow has been removed so it cannot advance `main`. + +For a prepared release in CI, the runner compares its checkout with the live +`origin/main` before invoking semantic-release, including during its dry run. +If `main` moved, start a new **WordPress Release** workflow +(`semantic-release.yml`) run with `workflow_dispatch`, selecting **main**. +This runs readiness and publishing against the new tip. Do not use **Re-run +jobs** on the old run: it retains the old commit. The preflight detects drift +but cannot prevent a later push, so keep `main` unchanged for the new run too. +Local dry runs on release branches and valid unprepared-release skips are +unaffected. + When `package.json` still equals the latest stable tag, no release is prepared. The guarded runner skips publishing successfully, even if newer commits would otherwise trigger semantic-release. Ordinary change PRs do not need to bump @@ -193,8 +208,8 @@ weekly GitHub Actions Dependabot updates maintain those pins. Validation jobs use read-only credentials and disable persisted checkout credentials. Only the publishing job receives release writes. Its dry run runs immediately before publishing because semantic-release verifies push permission even in -dry-run mode. Contributor automation separately receives repository-content -write permission and uses `GITHUB_TOKEN`; adding issues to the organization +dry-run mode. Contributor updates use reviewed pull requests rather than a +bot with repository-content write permission. Adding issues to the organization project still requires the configured Projects token. See the [dated dependency audit](dependency-audit-2026-10-23.md) for remaining development-tool findings and their owners. From 62730c3c7dc7fad3c8a7f8e711872bb14ba7f4fd Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Wed, 28 Oct 2026 23:47:05 -0500 Subject: [PATCH 053/100] docs: record the Drupal 7.3.0 release sync Pin the sync to the published tag and merge commit, retaining WordPress 2.1.0 as pending merge and release. Classify all nine commits after the prior review. Keep social metadata with SEO plugins, favicon behavior with Site Icon, and Drupal admin/legacy-settings tests platform-specific. Retain the prepared WordPress version model while adapting the development-tooling refresh. --- docs/release-notes-next.md | 11 ++++++++--- docs/sister-project-parity.md | 16 ++++++++++++---- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/docs/release-notes-next.md b/docs/release-notes-next.md index 1c8dcda..9749e0c 100644 --- a/docs/release-notes-next.md +++ b/docs/release-notes-next.md @@ -1,8 +1,10 @@ # 2.1.0 Release Notes Draft -Status date: 2026-10-23. Unreleased; the parity work and release preparation -must merge before publication. These notes describe changes since `2.0.0`, -including the [Drupal 7.3.0 sync](sister-project-parity.md#sync-log). +Status date: 2026-10-28. WordPress 2.1.0 remains unreleased; the parity work and +release preparation must merge before publication. These notes describe changes +since `2.0.0`, including the sync with [Drupal 7.3.0, published on October 28](https://github.com/emulsify-ds/emulsify-drupal/releases/tag/7.3.0). +The [sync log](sister-project-parity.md#sync-log) records the published tag and +the platform-specific classifications. ## Version strategy @@ -47,6 +49,9 @@ lineage. See the [release process](release-process.md). ## Changed +- Root development-only release tooling adopts Drupal 7.3.0's refreshed + semantic-release, GitHub plugin, commitlint, and lint-staged baselines. + WordPress retains its prepared, committed release-version model. - New generated themes use Emulsify Core `^4.5.0`, with an effective Node.js minimum of 24.13.0. Existing child manifests and lockfiles remain project-owned. diff --git a/docs/sister-project-parity.md b/docs/sister-project-parity.md index 41cdee2..ed47d13 100644 --- a/docs/sister-project-parity.md +++ b/docs/sister-project-parity.md @@ -85,19 +85,23 @@ This file may also hold Emulsify Core metadata such as `variant.structureImpleme | Sister reference | WordPress target | Status | | --- | --- | --- | -| Drupal 7.3.0: [7.2.2](https://github.com/emulsify-ds/emulsify-drupal/tree/7.2.2) plus [PR #382](https://github.com/emulsify-ds/emulsify-drupal/pull/382), pinned at [`1bc8fb3`](https://github.com/emulsify-ds/emulsify-drupal/commit/1bc8fb3bb64ae7014f50349930216b863976a5b2) | WordPress 2.1.0 | Implemented in [PR #47](https://github.com/emulsify-ds/emulsify-wordpress/pull/47); pending merge and release. | +| Drupal [7.3.0 tag](https://github.com/emulsify-ds/emulsify-drupal/tree/7.3.0), [published release](https://github.com/emulsify-ds/emulsify-drupal/releases/tag/7.3.0) on 2026-10-28, pinned at [`1f0f679`](https://github.com/emulsify-ds/emulsify-drupal/commit/1f0f679f4c1acd510d68391e952b3f29a1817a69) | WordPress 2.1.0 | Implemented in [PR #47](https://github.com/emulsify-ds/emulsify-wordpress/pull/47); pending merge and release. | -This sync includes the 7.2.2 fixes underlying the Drupal 7.3.0 reference. The -WordPress work is collected in one release branch by maintainer decision. +The published Drupal tag merges [PR #382](https://github.com/emulsify-ds/emulsify-drupal/pull/382) +at [`adc588c`](https://github.com/emulsify-ds/emulsify-drupal/commit/adc588c54cbde80a32324c845f702f7d7467cdc8). +This sync includes the 7.2.2 fixes and classifies all nine commits after the +previous [`1bc8fb3`](https://github.com/emulsify-ds/emulsify-drupal/commit/1bc8fb3bb64ae7014f50349930216b863976a5b2) +review pin. The WordPress work is collected in one release branch by maintainer decision. [PR #42](https://github.com/emulsify-ds/emulsify-wordpress/pull/42)'s audit-wrapper change is incorporated into PR #47; its separate PR is still open. These are -implementation references, not a claim that either release has been published. +implementation references; WordPress 2.1.0 still awaits merge and release. ### Ported or adapted | Area | Classification and reason | Implementation | | --- | --- | --- | | Release-version contracts and stale release claims | Adapt: WordPress publishes prepared, committed version metadata, and semantic-release must calculate the same version. | [PR #47](https://github.com/emulsify-ds/emulsify-wordpress/pull/47); [release procedure](release-process.md). | +| Root development-tool refresh ([`97eff27`](https://github.com/emulsify-ds/emulsify-drupal/commit/97eff270177f90726527cec674cd58de45f89d81)) | Adapt: refresh semantic-release, its GitHub plugin, commitlint, and lint-staged without changing theme runtime dependencies or WordPress's prepared-version model. | [PR #47](https://github.com/emulsify-ds/emulsify-wordpress/pull/47); [October 28 dependency audit](dependency-audit-2026-10-23.md#october-28-release-tooling-refresh). | | Core 4.5, lint/format exit status, ESM tests and coverage, audit JSON, and sandboxed accessibility runner | Port/adapt: shared frontend tooling retains WordPress's failures for empty test suites and empty accessibility audits. | [PR #47](https://github.com/emulsify-ds/emulsify-wordpress/pull/47), including [audit-wrapper PR #42](https://github.com/emulsify-ds/emulsify-wordpress/pull/42). | | Pager/menu current items, hidden Page prefix, and rendered accessibility audit | Adapt: Timber fallbacks and seven WordPress fixture routes replace Drupal render templates and routes. | [PR #47](https://github.com/emulsify-ds/emulsify-wordpress/pull/47); [accessibility contract](accessibility.md). | | Public Twig extension blocks and removal of the starter page override | Adapt: additive parent blocks preserve WordPress template inheritance and existing child overrides. | [PR #47](https://github.com/emulsify-ds/emulsify-wordpress/pull/47); [template extension contract](template-extension.md). | @@ -125,6 +129,10 @@ correct fallback language markup and failing link-color declarations. See the | Regions: `status`, `breadcrumb`, `highlighted`, `sidebar_first`, `sidebar_second` | Skip: WordPress uses menus, block patterns, and template parts; the parent registers no widget areas. Maintainers may revisit this choice. | | Container and form template suggestions, inline form-error IDs, status-message/feed/progress template moves, and select option attributes | Skip: these belong to Drupal's render and Form APIs. | | Favicon dark-mode icons, SVG sanitization, and generated social share image | Skip: WordPress uses core Site Icon and `custom-logo`; SEO plugins own `og:image`. | +| Social-share settings, previews, resizing, fallback metadata, and related favicon previews ([`b312b4c`](https://github.com/emulsify-ds/emulsify-drupal/commit/b312b4c5fe964d533b245fb2fbef6def6ea419f4), [`66ac7ab`](https://github.com/emulsify-ds/emulsify-drupal/commit/66ac7abf8baa9658b019bbaf177a33addd117fc5), [`1b0a439`](https://github.com/emulsify-ds/emulsify-drupal/commit/1b0a439c1c2fb0fced3be431e2096e3d6d799dcf), [`96695b9`](https://github.com/emulsify-ds/emulsify-drupal/commit/96695b988980d101fb3955dc3b26c41ab05172a6), [`bf6cc80`](https://github.com/emulsify-ds/emulsify-drupal/commit/bf6cc800e05b0c55490658073543e29099d2e186), [`dec4732`](https://github.com/emulsify-ds/emulsify-drupal/commit/dec47321958d094adc0dc0c6991c0c002f6b4ea4)) | Skip: SEO plugins own WordPress `og:image` and sharing metadata; core Site Icon owns the favicon behavior. The parent adds neither settings system. | +| Favicon colors and clean-state notice ([`145c676`](https://github.com/emulsify-ds/emulsify-drupal/commit/145c67617be67336fbb8dd24eb4758bc69aed2d9)) | Skip: WordPress uses Site Icon, not Drupal's favicon settings and preview UI. | +| Configured admin theme in the upgrade smoke ([`adc588c`](https://github.com/emulsify-ds/emulsify-drupal/commit/adc588c54cbde80a32324c845f702f7d7467cdc8)) | Skip: this tests Drupal's admin-theme configuration and has no WordPress equivalent. | +| semantic-release-owned package version ([`97eff27`](https://github.com/emulsify-ds/emulsify-drupal/commit/97eff270177f90726527cec674cd58de45f89d81)) | Skip: Drupal uses `@semantic-release/npm` with `npmPublish: false`, then `@semantic-release/git`, to write and commit npm version metadata. By maintainer decision, WordPress keeps its prepared, committed `package.json` version and matching theme headers and starter lineage; semantic-release must compute that exact version. The commit's Drupal legacy-settings fixtures are also platform-specific. See the [release procedure](release-process.md). | | Default parent logo, library/breakpoint contract, Drupal.org channel-skew validation, Stable 9 parity, and the `dr` CLI | Skip: these are Drupal-only runtime and distribution contracts. | | Favicon SVG processing fixes ([`6cb1101`](https://github.com/emulsify-ds/emulsify-drupal/commit/6cb1101), [`16ecce0`](https://github.com/emulsify-ds/emulsify-drupal/commit/16ecce0), [`4a10b74`](https://github.com/emulsify-ds/emulsify-drupal/commit/4a10b74)) | Skip: WordPress uses core Site Icon. | | Favicon preview styling in dark admin themes ([`6b8b922`](https://github.com/emulsify-ds/emulsify-drupal/commit/6b8b922)) | Skip: WordPress has no favicon generator. | From f1e2c1f8f8fcd127df203818645a8931329735c5 Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Wed, 28 Oct 2026 23:47:31 -0500 Subject: [PATCH 054/100] chore(deps-dev): refresh release tooling to match Drupal 7.3.0 Update semantic-release, its GitHub plugin, commitlint and lint-staged to the published Drupal baselines. Keep js-yaml pinned and all theme versions, Composer dependencies, Whisk ranges and release plugins unchanged. Record identical before/after root audit results: six dev-only package entries in the npm bundle remain, while both production-only audits report zero. All 96 Node tests pass, including packed-Core and browser coverage. ZIP entry hashes confirm every non-documentation payload file is unchanged. --- docs/dependency-audit-2026-10-23.md | 66 ++++++ package-lock.json | 331 +++++++++++++--------------- package.json | 10 +- 3 files changed, 226 insertions(+), 181 deletions(-) diff --git a/docs/dependency-audit-2026-10-23.md b/docs/dependency-audit-2026-10-23.md index 93a66f5..517fc59 100644 --- a/docs/dependency-audit-2026-10-23.md +++ b/docs/dependency-audit-2026-10-23.md @@ -74,3 +74,69 @@ The clean install succeeds. Both full audits exit 1 because findings remain; the runtime audit exits 0. The generated-theme contract tests verify the YAML guidance checks with the updated parser. Results are point-in-time registry observations, and should be refreshed before the release. + +## October 28 release-tooling refresh + +The snapshots above remain the October 23 observations. On October 28, the +root tooling was refreshed to the baselines in the published Drupal 7.3.0 +release, using Node 24.22.1 and npm 11.19.1: + +| Direct development dependency | Range before → after | Locked version before → after | +| --- | --- | --- | +| `semantic-release` | `^25.0.8` → `^25.0.9` | 25.0.8 → 25.0.9 | +| `@semantic-release/github` | `^12.0.9` → `^12.0.10` | 12.0.9 → 12.0.10 | +| `@commitlint/cli` | `^21.2.1` → `^21.2.4` | 21.2.1 → 21.2.4 | +| `@commitlint/config-conventional` | `^21.2.0` → `^21.2.4` | 21.2.0 → 21.2.4 | +| `lint-staged` | `^17.3.0` → `^17.6.0` | 17.3.0 → 17.6.0 | + +Only those five `devDependencies` and their lockfile resolutions change. +`js-yaml` stays pinned and locked at 4.3.2. Theme versions, Composer dependencies, +Whisk dependency ranges, release plugins and the prepared-version policy are +unchanged. The update uses `npm install --save-dev --ignore-scripts`, followed +by a clean `npm ci --ignore-scripts`; no audit fix or overrides were applied. + +### Before and after findings + +| Audit | Before refresh | After clean installation | Exit status | +| --- | --- | --- | --- | +| `npm audit --json` | 6 package entries: 5 high, 1 moderate | Same 6 entries and severities | 1 both times | +| `npm audit --omit=dev --json` | 0 findings | 0 findings | 0 both times | + +The before snapshot already differs from October 23's seven entries; that +point-in-time registry difference is not a result of this refresh. None of the +six October 28 findings was fixed by the five targeted updates: + +| Package | Installed before and after | Severity | Dependency owner | Ships in parent ZIP? | +| --- | --- | --- | --- | --- | +| `brace-expansion` | 5.0.7 | High | npm 11.18.0's bundled dependencies | No | +| `ip-address` | 10.2.0 | High | npm 11.18.0's bundled dependencies | No | +| `postcss-selector-parser` | 7.1.4 | Moderate | npm 11.18.0's bundled dependencies | No | +| `tar` | 7.5.19 | High | npm 11.18.0's bundled dependencies | No | +| `undici` | 6.27.0 | High | npm 11.18.0's bundled dependencies | No | +| `npm` | 11.18.0 | High, propagated via `tar` | `@semantic-release/npm` 13.1.5 | No | + +All six paths are under `semantic-release` → `@semantic-release/npm` 13.1.5 → +its bundled npm 11.18.0. The npm plugin still accepts npm `^11.6.2`; evaluating +a separate update of that bundle remains a maintainer follow-up. This plugin +is installed as a semantic-release dependency, but WordPress's explicit release +plugin list does not use it to write or publish npm version metadata. The npm +entry is propagated, so these counts are package entries, not six independent +vulnerabilities. + +The parent ZIP excludes root npm manifests, `node_modules`, and release tools; +it contains production Composer dependencies instead. A before/after ZIP +comparison at the same Git HEAD confirmed 616 identical entry paths and +byte-identical runtime/vendor files; only documentation changed. This audit +covers the root npm tree, not Composer +packages or dependencies installed separately by generated child themes. + +Reproduce the targeted update and audit with: + +```sh +npm audit --json +npm audit --omit=dev --json +npm install --save-dev --ignore-scripts 'semantic-release@^25.0.9' '@semantic-release/github@^12.0.10' '@commitlint/cli@^21.2.4' '@commitlint/config-conventional@^21.2.4' 'lint-staged@^17.6.0' +npm ci --ignore-scripts +npm audit --json +npm audit --omit=dev --json +``` diff --git a/package-lock.json b/package-lock.json index ba1e65e..14094c4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,15 +9,15 @@ "version": "2.1.0", "license": "GPL-2.0-only", "devDependencies": { - "@commitlint/cli": "^21.2.1", - "@commitlint/config-conventional": "^21.2.0", + "@commitlint/cli": "^21.2.4", + "@commitlint/config-conventional": "^21.2.4", "@semantic-release/commit-analyzer": "^13.0.1", - "@semantic-release/github": "^12.0.9", + "@semantic-release/github": "^12.0.10", "@semantic-release/release-notes-generator": "^14.1.1", "husky": "^9.1.7", "js-yaml": "4.3.2", - "lint-staged": "^17.3.0", - "semantic-release": "^25.0.8" + "lint-staged": "^17.6.0", + "semantic-release": "^25.0.9" }, "engines": { "node": ">=24.10" @@ -109,18 +109,18 @@ } }, "node_modules/@commitlint/cli": { - "version": "21.2.1", - "resolved": "https://registry.npmjs.org/@commitlint/cli/-/cli-21.2.1.tgz", - "integrity": "sha512-blsZGe29hJ72VGEFVl72IVYX+1vsfINpjA9yWQA6i7OKD/McGEOXg08sKIRKjFk4JvzhV/9n0l3i6NooPLTNfg==", + "version": "21.2.4", + "resolved": "https://registry.npmjs.org/@commitlint/cli/-/cli-21.2.4.tgz", + "integrity": "sha512-g3M/wrQdElqGH5vROIeL2QiYIxrzqUkhCgsDyDlWfKEyQdCt10OyMB28W2cJtpZ+VNGMvmaN923infr1hOfyrg==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/config-conventional": "^21.2.0", - "@commitlint/format": "^21.2.0", - "@commitlint/lint": "^21.2.0", - "@commitlint/load": "^21.2.0", - "@commitlint/read": "^21.2.1", - "@commitlint/types": "^21.2.0", + "@commitlint/config-conventional": "^21.2.4", + "@commitlint/format": "^21.2.3", + "@commitlint/lint": "^21.2.4", + "@commitlint/load": "^21.2.4", + "@commitlint/read": "^21.2.3", + "@commitlint/types": "^21.2.3", "tinyexec": "^1.0.0", "yargs": "^18.0.0" }, @@ -132,13 +132,13 @@ } }, "node_modules/@commitlint/config-conventional": { - "version": "21.2.0", - "resolved": "https://registry.npmjs.org/@commitlint/config-conventional/-/config-conventional-21.2.0.tgz", - "integrity": "sha512-Qf8WRDVcyVd14if6VTWenebxFbKnVnbzPUJjlzjkyJGeHK2xCGd63Dr1XZzj0plXKQb9P0BfOxoc1HVeCo2BWQ==", + "version": "21.2.4", + "resolved": "https://registry.npmjs.org/@commitlint/config-conventional/-/config-conventional-21.2.4.tgz", + "integrity": "sha512-IRldtwgNmR0SySR7wizFbhRmq45lPt6H3XCNTMyVN7jrPLgi8JlTRR6Eg1HLWUQt9P1Lh3k6eY25ilDa47EE6w==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/types": "^21.2.0", + "@commitlint/types": "^21.2.3", "conventional-changelog-conventionalcommits": "^10.0.0" }, "engines": { @@ -146,13 +146,13 @@ } }, "node_modules/@commitlint/config-validator": { - "version": "21.2.0", - "resolved": "https://registry.npmjs.org/@commitlint/config-validator/-/config-validator-21.2.0.tgz", - "integrity": "sha512-t7AzNHAKeIdo/3NRGwzpufKHsKkPHmFs/56N2Fnsh0/r0rGtnQzTxk6vnFgjaGr4hdSQKNB50/KAhR9Yk4LJKA==", + "version": "21.2.3", + "resolved": "https://registry.npmjs.org/@commitlint/config-validator/-/config-validator-21.2.3.tgz", + "integrity": "sha512-JQ13v0GafSM4fBYX8UowSy++07Was20auq0NcTdnzSEq6/05Cq5GBUMIfKkqjzyBwC9sJFmPpKWgLLhH7cygkw==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/types": "^21.2.0", + "@commitlint/types": "^21.2.3", "ajv": "^8.11.0" }, "engines": { @@ -160,13 +160,13 @@ } }, "node_modules/@commitlint/ensure": { - "version": "21.2.0", - "resolved": "https://registry.npmjs.org/@commitlint/ensure/-/ensure-21.2.0.tgz", - "integrity": "sha512-76IF9vDNS13lAzEEik9eKwzt8f9hYhWiwVXZ2AnyLCz5/f511FsEQ3pw1X3/zSQpdRLQU7i5qDMVKyXi1GWjSg==", + "version": "21.2.4", + "resolved": "https://registry.npmjs.org/@commitlint/ensure/-/ensure-21.2.4.tgz", + "integrity": "sha512-woLOLV4pBMUBLyr5+MDM3WSG1r+eb2DObkysFSDxqyDUE4c6hZUOrpebJb2f8XcP3zKGJLb8H5w2suERVGI+Ag==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/types": "^21.2.0", + "@commitlint/types": "^21.2.3", "es-toolkit": "^1.46.0" }, "engines": { @@ -184,13 +184,13 @@ } }, "node_modules/@commitlint/format": { - "version": "21.2.0", - "resolved": "https://registry.npmjs.org/@commitlint/format/-/format-21.2.0.tgz", - "integrity": "sha512-c4q64xaav2U83t7k7RyzJerBZurPer7FxUOY0RL5L/6CZijZ7K+s6HIBGIghj0ey1P2+seRX0J9XQYtDued6tg==", + "version": "21.2.3", + "resolved": "https://registry.npmjs.org/@commitlint/format/-/format-21.2.3.tgz", + "integrity": "sha512-KKwCjXYqA8r295uhLnulDXL9yekdXmj2yp2Q0XsBPhxQ2iPuvRE8F43j7g7yexo69wTdmUQmFz15jpderluzUQ==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/types": "^21.2.0", + "@commitlint/types": "^21.2.3", "picocolors": "^1.1.1" }, "engines": { @@ -198,13 +198,13 @@ } }, "node_modules/@commitlint/is-ignored": { - "version": "21.2.0", - "resolved": "https://registry.npmjs.org/@commitlint/is-ignored/-/is-ignored-21.2.0.tgz", - "integrity": "sha512-4/eB0vBN7L88O/oC4ajAEqi7j2ZfNgxl/+11RfAV9YosejZgDXhY2C9VcHnHJhOzPLoSy5P3Mg/46kqeyJfXKw==", + "version": "21.2.3", + "resolved": "https://registry.npmjs.org/@commitlint/is-ignored/-/is-ignored-21.2.3.tgz", + "integrity": "sha512-Ng4nRj/LBAOvc+GQu7bsFW4/fL89LvZ14OOC/ERLbUpnG6QBwWRzY/1oXGwPs3C8YHWjJObin4kLYo2YeEn70w==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/types": "^21.2.0", + "@commitlint/types": "^21.2.3", "semver": "^7.6.0" }, "engines": { @@ -212,36 +212,36 @@ } }, "node_modules/@commitlint/lint": { - "version": "21.2.0", - "resolved": "https://registry.npmjs.org/@commitlint/lint/-/lint-21.2.0.tgz", - "integrity": "sha512-ceO5dp9pLjEZ6y6qbq/uXWXDPykqqlTsyzoQ0NzecpisSJhK3kTy9qzQoPeJuWG/IMNdV1lO0RgmzqoAlSi1uw==", + "version": "21.2.4", + "resolved": "https://registry.npmjs.org/@commitlint/lint/-/lint-21.2.4.tgz", + "integrity": "sha512-HR8SGl1oEGfi/cqyBipanVY0a9CfEzIcJjPC37t7oQedaUUngw3d5E17g5Cfr2S3p6KsZM7UOHFq5fXHLRh7DQ==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/is-ignored": "^21.2.0", - "@commitlint/parse": "^21.2.0", - "@commitlint/rules": "^21.2.0", - "@commitlint/types": "^21.2.0" + "@commitlint/is-ignored": "^21.2.3", + "@commitlint/parse": "^21.2.3", + "@commitlint/rules": "^21.2.4", + "@commitlint/types": "^21.2.3" }, "engines": { "node": ">=22.12.0" } }, "node_modules/@commitlint/load": { - "version": "21.2.0", - "resolved": "https://registry.npmjs.org/@commitlint/load/-/load-21.2.0.tgz", - "integrity": "sha512-RjlzWQqruRwIenJEfZtq7kG97co97nKoHpflE5YnF61tDLXxHPrdWImgzw6VL6MlFyaOcVlk74eBV8ZQmc3oIA==", + "version": "21.2.4", + "resolved": "https://registry.npmjs.org/@commitlint/load/-/load-21.2.4.tgz", + "integrity": "sha512-Gi+aAW9nL8XPgvg8kIO0gZOw1/ePvpDLkv1bm8tj0RqenhNtCzk0frtctJhl41uIAP7OSx9bgQ6HCOa9En4g2Q==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/config-validator": "^21.2.0", + "@commitlint/config-validator": "^21.2.3", "@commitlint/execute-rule": "^21.0.1", - "@commitlint/resolve-extends": "^21.2.0", - "@commitlint/types": "^21.2.0", + "@commitlint/resolve-extends": "^21.2.3", + "@commitlint/types": "^21.2.3", "cosmiconfig": "^9.0.1", - "cosmiconfig-typescript-loader": "^6.1.0", "es-toolkit": "^1.46.0", "is-plain-obj": "^4.1.0", + "jiti": "^2.6.1", "picocolors": "^1.1.1" }, "engines": { @@ -259,13 +259,13 @@ } }, "node_modules/@commitlint/parse": { - "version": "21.2.0", - "resolved": "https://registry.npmjs.org/@commitlint/parse/-/parse-21.2.0.tgz", - "integrity": "sha512-QHWxG4d0PLTF634/AdyZ0MQS+CLn5YOuJlCFhMMlSGKFxzYGUetkHBj18xgBD+6fVzUrA2lrCdi/vlS2f/oYXg==", + "version": "21.2.3", + "resolved": "https://registry.npmjs.org/@commitlint/parse/-/parse-21.2.3.tgz", + "integrity": "sha512-Jt61QpF1xA88damCELe8I0FlDZPSpXNc8IhN7z5kZ0EcDYPjdZ+ZHPvsSXl69r2MIlLnNKeHhOn2cmVpziFE9w==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/types": "^21.2.0", + "@commitlint/types": "^21.2.3", "conventional-changelog-angular": "^9.0.0", "conventional-commits-parser": "^7.0.0" }, @@ -274,14 +274,14 @@ } }, "node_modules/@commitlint/read": { - "version": "21.2.1", - "resolved": "https://registry.npmjs.org/@commitlint/read/-/read-21.2.1.tgz", - "integrity": "sha512-hUW7EJQnNTL0vPOmVMNK4CrnrNBN0nN+JJHReFkdHO5y4iyHeEmTBwuC15OCqUTjxWo7idnH1LftfpWVIaPWIA==", + "version": "21.2.3", + "resolved": "https://registry.npmjs.org/@commitlint/read/-/read-21.2.3.tgz", + "integrity": "sha512-szAepuLA1vTTr7fuudEg7jYyVHudf+FH+vptU91bhxE1WnE8TKrfTAi44HVllz8XpI8Pvp4GjR/iP5Nshgh+og==", "dev": true, "license": "MIT", "dependencies": { "@commitlint/top-level": "^21.2.0", - "@commitlint/types": "^21.2.0", + "@commitlint/types": "^21.2.3", "@conventional-changelog/git-client": "^3.0.0", "tinyexec": "^1.0.0" }, @@ -290,22 +290,22 @@ } }, "node_modules/@commitlint/read/node_modules/@conventional-changelog/git-client": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@conventional-changelog/git-client/-/git-client-3.1.0.tgz", - "integrity": "sha512-Tqa/gHco2WJWa740NRjOrfKVvzIqxkZpecb8bemaQ8sKM5PXb1UK4uTyTb/1wIqNuOVaDOFxyBdhTIQZn6gdjQ==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@conventional-changelog/git-client/-/git-client-3.2.0.tgz", + "integrity": "sha512-sD/E0vzpkoDz/dqM1Hhv0ihC4KBw6RXjefeO8SG1XrX+wGkMxe1pKeedkKDPCtr2JjuhbuS9aJNgOvnT8hbeBw==", "dev": true, "license": "MIT", "dependencies": { "@simple-libs/child-process-utils": "^2.0.0", "@simple-libs/stream-utils": "^2.0.0", - "semver": "^7.5.2" + "verkit": "^0.5.0" }, "engines": { "node": ">=22" }, "peerDependencies": { "conventional-commits-filter": "^6.0.1", - "conventional-commits-parser": "^7.0.1" + "conventional-commits-parser": "^7.1.3" }, "peerDependenciesMeta": { "conventional-commits-filter": { @@ -330,14 +330,14 @@ } }, "node_modules/@commitlint/resolve-extends": { - "version": "21.2.0", - "resolved": "https://registry.npmjs.org/@commitlint/resolve-extends/-/resolve-extends-21.2.0.tgz", - "integrity": "sha512-4O/1j51+79Wth9s/MGxt/5gs0XYLDgNlYpltQfhAvLE0itusLKs9zruxbiNg1oOkmkb9L9L4USYGjEj7n87NxA==", + "version": "21.2.3", + "resolved": "https://registry.npmjs.org/@commitlint/resolve-extends/-/resolve-extends-21.2.3.tgz", + "integrity": "sha512-bqJdmAtXgAQel+l02uIsF7rHgKgx1AGZdGBjam7uK4XW54ZAQtB6XAIUXezYprQiCcRMpBh4fHs5JodXK4mVZA==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/config-validator": "^21.2.0", - "@commitlint/types": "^21.2.0", + "@commitlint/config-validator": "^21.2.3", + "@commitlint/types": "^21.2.3", "es-toolkit": "^1.46.0", "global-directory": "^5.0.0", "resolve-from": "^5.0.0" @@ -347,16 +347,16 @@ } }, "node_modules/@commitlint/rules": { - "version": "21.2.0", - "resolved": "https://registry.npmjs.org/@commitlint/rules/-/rules-21.2.0.tgz", - "integrity": "sha512-C2yXMNpiB8ETZKfx5JD8+ExgF8vTU1VQMKPSUUYwqKpw9oJWQBrlXBpdU038mj2WPjof7o9UzFpmTyBeGMZwZg==", + "version": "21.2.4", + "resolved": "https://registry.npmjs.org/@commitlint/rules/-/rules-21.2.4.tgz", + "integrity": "sha512-wranK/aevXs/WiW4N3orQlXY4sU2pQWjRSjN7I3TsZmP146Rr4CpsG38lpyqVcbNfMgvUVGy7ffYm7nCziTUwg==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/ensure": "^21.2.0", + "@commitlint/ensure": "^21.2.4", "@commitlint/message": "^21.2.0", "@commitlint/to-lines": "^21.0.1", - "@commitlint/types": "^21.2.0" + "@commitlint/types": "^21.2.3" }, "engines": { "node": ">=22.12.0" @@ -386,9 +386,9 @@ } }, "node_modules/@commitlint/types": { - "version": "21.2.0", - "resolved": "https://registry.npmjs.org/@commitlint/types/-/types-21.2.0.tgz", - "integrity": "sha512-7zVFCDB2reMvJH5dmbKnOQPjZEvjdJTH8jc0U/PIPU1r3/+vf5pD1HlfitV2MWsWXrvu7u39iY1lyLUPOaN0Gw==", + "version": "21.2.3", + "resolved": "https://registry.npmjs.org/@commitlint/types/-/types-21.2.3.tgz", + "integrity": "sha512-sGEA473TlCvhCGNoTBY9OXZVWdscIXdGt0dm8EpB+2MRfmt8LNh/X56P/T3nCtEB+eH5qGEa3odphSAoUxSfSQ==", "dev": true, "license": "MIT", "dependencies": { @@ -400,9 +400,9 @@ } }, "node_modules/@conventional-changelog/template": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@conventional-changelog/template/-/template-1.2.1.tgz", - "integrity": "sha512-TzlTVpKPjaqW6qOYjQcYUDuGsLCNsvFHVBXkYGTAnf5V37jCWrE5haKNXzz0WZUtVHjrpV76L1buANjwXMfT8w==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@conventional-changelog/template/-/template-1.4.0.tgz", + "integrity": "sha512-aalGyl7dbB5PArRebDIX43ZvBlXrYm9uWzGJ26t+4SzJVPsOuvfILGGbw5X4yX7i50YEmJ8zvbiWnqH/AAnZqg==", "dev": true, "license": "MIT", "engines": { @@ -475,13 +475,13 @@ "license": "MIT" }, "node_modules/@octokit/plugin-paginate-rest": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-14.0.0.tgz", - "integrity": "sha512-fNVRE7ufJiAA3XUrha2omTA39M6IXIc6GIZLvlbsm8QOQCYvpq/LkMNGyFlB1d8hTDzsAXa3OKtybdMAYsV/fw==", + "version": "15.0.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-15.0.0.tgz", + "integrity": "sha512-lw9A9YL5s4VPj+VEx8uMxaxQm2YTgrPDkrLEuZuu18R8TK3oPcXF4K6t52nx6xn1RcogZC9i188sAr/4XYJaQQ==", "dev": true, "license": "MIT", "dependencies": { - "@octokit/types": "^16.0.0" + "@octokit/types": "^17.0.0" }, "engines": { "node": ">= 20" @@ -490,6 +490,23 @@ "@octokit/core": ">=6" } }, + "node_modules/@octokit/plugin-paginate-rest/node_modules/@octokit/openapi-types": { + "version": "28.0.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-28.0.0.tgz", + "integrity": "sha512-0rFyLuyHvIj6uuZWuDslxkowFYdPXoNIkeAv4b27dzm2Tf4vGWXnPsMcxs7d65kLdMERgP3wc1AEPlqMz8e1cQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@octokit/plugin-paginate-rest/node_modules/@octokit/types": { + "version": "17.0.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-17.0.0.tgz", + "integrity": "sha512-ByP1v7YL5SMveFPP7+sj0/ZuWCOOg/Chs4NafOMpq6WNIM/hdGY0S7C0TCGDBWu1aGmOxmUIhMx3cO+IdwYZ1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^28.0.0" + } + }, "node_modules/@octokit/plugin-retry": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/@octokit/plugin-retry/-/plugin-retry-8.1.0.tgz", @@ -672,14 +689,14 @@ } }, "node_modules/@semantic-release/github": { - "version": "12.0.9", - "resolved": "https://registry.npmjs.org/@semantic-release/github/-/github-12.0.9.tgz", - "integrity": "sha512-ODIqb0V3QqndipryEEiaBxUQCFjvv7Oese5Dt4omMGa60YRNEW0Sx3K+zri0uac2Y6S9nOlMehciWIzvvRCTGQ==", + "version": "12.0.10", + "resolved": "https://registry.npmjs.org/@semantic-release/github/-/github-12.0.10.tgz", + "integrity": "sha512-QskygvpX8hFx9UEbqJ+C46OIhaJjsysNQ8PYJPuRDc/ek5+dh8WYGD1ofUahCcGLAOmnnwZTcmCJCEXM8XxhRw==", "dev": true, "license": "MIT", "dependencies": { "@octokit/core": "^7.0.0", - "@octokit/plugin-paginate-rest": "^14.0.0", + "@octokit/plugin-paginate-rest": "^15.0.0", "@octokit/plugin-retry": "^8.0.0", "@octokit/plugin-throttling": "^11.0.0", "@semantic-release/error": "^4.0.0", @@ -3009,17 +3026,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@types/node": { - "version": "26.1.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.2.tgz", - "integrity": "sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "undici-types": "~8.3.0" - } - }, "node_modules/@types/normalize-package-data": { "version": "2.4.4", "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz", @@ -3111,9 +3117,9 @@ "license": "Python-2.0" }, "node_modules/argue-cli": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/argue-cli/-/argue-cli-3.1.0.tgz", - "integrity": "sha512-DhBpBfXL4SS2uC0N922MMajKR3CdrTG0u2or1PNYgXMsrSzViJrbtvT0nCLlLGUI0plam/ZZCs7aAauHtW9thw==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/argue-cli/-/argue-cli-3.2.0.tgz", + "integrity": "sha512-VipTB0gXgGIFO2Rg9yEVN5wLt2AurJcZqDbgmYSwPwsykhLrQhs240/bfceev4w68lI8JshoOJIk9uW7tFzROw==", "dev": true, "license": "MIT", "engines": { @@ -3534,26 +3540,26 @@ } }, "node_modules/conventional-changelog-angular": { - "version": "9.2.1", - "resolved": "https://registry.npmjs.org/conventional-changelog-angular/-/conventional-changelog-angular-9.2.1.tgz", - "integrity": "sha512-oWSL6ZhnXbYraOFTK3PgRAQJ8fADDAEv5K6AdeyQPLvjFmhG8+ejL0jZZp/R7vTmGJaBvZEE+sE7dB4bCv7sAw==", + "version": "9.4.0", + "resolved": "https://registry.npmjs.org/conventional-changelog-angular/-/conventional-changelog-angular-9.4.0.tgz", + "integrity": "sha512-HdxRxuS8bBXVIuo4V82gvSwAXT0vYQUizrjs/izmPg5JdDstr8v8I5hduGL3iQbG+o310dUDxC4+LetuS5hu9w==", "dev": true, "license": "ISC", "dependencies": { - "@conventional-changelog/template": "^1.2.1" + "@conventional-changelog/template": "^1.4.0" }, "engines": { "node": ">=22" } }, "node_modules/conventional-changelog-conventionalcommits": { - "version": "10.2.1", - "resolved": "https://registry.npmjs.org/conventional-changelog-conventionalcommits/-/conventional-changelog-conventionalcommits-10.2.1.tgz", - "integrity": "sha512-n4Kr1HFMTf3iMbES0TMxKIcYtUUv4rKqyQQp2JwfOEfFCOfGT3Tq4mCyJ8S9/YPyWhydjfKrrvnyl+gCjA+mJQ==", + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/conventional-changelog-conventionalcommits/-/conventional-changelog-conventionalcommits-10.4.1.tgz", + "integrity": "sha512-jS1LRFSbRoAr6JbhhRy1JYfNNUj5LM3+v49hRE03CgccsiYqgdVj/RTmrw4J1rylrqijHwdOlJWiJa61JHOgHg==", "dev": true, "license": "ISC", "dependencies": { - "@conventional-changelog/template": "^1.2.1" + "@conventional-changelog/template": "^1.4.0" }, "engines": { "node": ">=22" @@ -3590,9 +3596,9 @@ } }, "node_modules/conventional-commits-parser": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/conventional-commits-parser/-/conventional-commits-parser-7.1.2.tgz", - "integrity": "sha512-O+x4N2yH+ijvqWlIyTHsXTAP+algNWgGbjY2duCe8w2vUMvUB95cLRslCPfTMQyLAKlet3bhZTdu6ozn4M+QJQ==", + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/conventional-commits-parser/-/conventional-commits-parser-7.1.3.tgz", + "integrity": "sha512-Q9oWm1o9uy/oEsZQIraoFMZ4RuPD+79KgwWj6mJVP356tazolCB0piRLZ5Kz9te9p027uSEcUW+Gu44Eh+X79g==", "dev": true, "license": "MIT", "dependencies": { @@ -3666,24 +3672,6 @@ } } }, - "node_modules/cosmiconfig-typescript-loader": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/cosmiconfig-typescript-loader/-/cosmiconfig-typescript-loader-6.3.0.tgz", - "integrity": "sha512-Akr82WH1Wfqatyiqpj8HDkO2o2KmJRu1FhKfSNJP3K4IdXwHfEyL7MOb62i1AGQVLtIQM+iCE9CGOtrfhR+mmA==", - "dev": true, - "license": "MIT", - "dependencies": { - "jiti": "2.6.1" - }, - "engines": { - "node": ">=v18" - }, - "peerDependencies": { - "@types/node": "*", - "cosmiconfig": ">=9", - "typescript": ">=5" - } - }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -3998,15 +3986,16 @@ } }, "node_modules/es-toolkit": { - "version": "1.50.0", - "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.50.0.tgz", - "integrity": "sha512-OyZKhUVvEep9ITEiwHn8GKnMRQIVqoSIX7WnRbkWgJkllCujilqP2rD0u979tkl8wqyc8ICwlc1UBVv/Sl1G6w==", + "version": "1.53.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.53.0.tgz", + "integrity": "sha512-3+AYXp9fmJ/UhHBMYj2/U+RaUouMD90SzoItyDOB2Za7W6dB7fQApFGnZPl2MF6K8/Tv1koh1JyirSx/kju6Pw==", "dev": true, "license": "MIT", "workspaces": [ "docs", "benchmarks", - "tests/types" + "tests/types", + "tests/browser-compat" ] }, "node_modules/escalade": { @@ -4518,9 +4507,9 @@ } }, "node_modules/jiti": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", - "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", "dev": true, "license": "MIT", "bin": { @@ -4606,15 +4595,15 @@ "license": "MIT" }, "node_modules/lint-staged": { - "version": "17.3.0", - "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-17.3.0.tgz", - "integrity": "sha512-woZS3vNe3UKqBaLPvbLOtKRY4tLANpWQhom12MGWqC8Mh1lCOO+WgSwmX2amjJAqTY9BkXYW87fCUH5H9Ph6xw==", + "version": "17.6.0", + "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-17.6.0.tgz", + "integrity": "sha512-nOhBfYkZEwkFtNzD5pK9s9LkcWoxJ+whLzEnhqi9nl7zTB5n7tg8+9iYgMGx2Wm3VlLu8cbNj0RACzY+nWi5iQ==", "dev": true, "license": "MIT", "dependencies": { - "picomatch": "^4.0.5", + "picomatch": "^4.0.7", "string-argv": "^0.3.2", - "tinyexec": "^1.2.4" + "tinyexec": "^1.3.1" }, "bin": { "lint-staged": "bin/lint-staged.js" @@ -4626,13 +4615,13 @@ "url": "https://opencollective.com/lint-staged" }, "optionalDependencies": { - "yaml": "^2.9.0" + "yaml": "^2.9.1" } }, "node_modules/lint-staged/node_modules/picomatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", - "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "dev": true, "license": "MIT", "engines": { @@ -5510,9 +5499,9 @@ "license": "MIT" }, "node_modules/semantic-release": { - "version": "25.0.8", - "resolved": "https://registry.npmjs.org/semantic-release/-/semantic-release-25.0.8.tgz", - "integrity": "sha512-w/iZ0bur36rKffXZYmIUmy068eoBY3Ij1DCCddx2JwWEM5Tg+eU9ld/E9qSInVvPASyyR2Ln/XGfQ9OZrMlhtw==", + "version": "25.0.9", + "resolved": "https://registry.npmjs.org/semantic-release/-/semantic-release-25.0.9.tgz", + "integrity": "sha512-bxve7csK0/Txr++CkfrmV+X1r4jqiSOw2WsSad9E2S68R+ZfLBwDn8IceM8WfiOmKQIHgsQc1cNA8Dzg7U75pg==", "dev": true, "license": "MIT", "dependencies": { @@ -6263,9 +6252,9 @@ } }, "node_modules/tinyexec": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", - "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", "dev": true, "license": "MIT", "engines": { @@ -6369,21 +6358,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/typescript": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", - "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", - "dev": true, - "license": "Apache-2.0", - "peer": true, - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, "node_modules/uglify-js": { "version": "3.19.3", "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", @@ -6408,14 +6382,6 @@ "node": ">=20.18.1" } }, - "node_modules/undici-types": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", - "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", - "dev": true, - "license": "MIT", - "peer": true - }, "node_modules/unicode-emoji-modifier-base": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unicode-emoji-modifier-base/-/unicode-emoji-modifier-base-1.0.0.tgz", @@ -6500,6 +6466,19 @@ "spdx-expression-parse": "^3.0.0" } }, + "node_modules/verkit": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/verkit/-/verkit-0.5.0.tgz", + "integrity": "sha512-u4J1hr6Y+C/hit34GwL3eIcUf3wngT8TlYUjK7tk0jxydKLAq3sYvifjdTDA6U3KCKRcQCQDGf/srjxfZF3l7g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, "node_modules/web-worker": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/web-worker/-/web-worker-1.5.0.tgz", @@ -6587,9 +6566,9 @@ } }, "node_modules/yaml": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", - "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz", + "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==", "dev": true, "license": "ISC", "optional": true, diff --git a/package.json b/package.json index e2d862c..16708f8 100644 --- a/package.json +++ b/package.json @@ -67,14 +67,14 @@ "semantic-release": "semantic-release" }, "devDependencies": { - "@commitlint/cli": "^21.2.1", - "@commitlint/config-conventional": "^21.2.0", + "@commitlint/cli": "^21.2.4", + "@commitlint/config-conventional": "^21.2.4", "@semantic-release/commit-analyzer": "^13.0.1", - "@semantic-release/github": "^12.0.9", + "@semantic-release/github": "^12.0.10", "@semantic-release/release-notes-generator": "^14.1.1", "husky": "^9.1.7", "js-yaml": "4.3.2", - "lint-staged": "^17.3.0", - "semantic-release": "^25.0.8" + "lint-staged": "^17.6.0", + "semantic-release": "^25.0.9" } } From 64fef877e561a0d97829289c57753f07a6902c1d Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Thu, 29 Oct 2026 00:13:26 -0500 Subject: [PATCH 055/100] fix(templates): print WordPress-formatted text without double escaping Use Timber's WordPress escapers at each output boundary: KSES for post headings and title links, esc_html for plain names and route titles, and wp_strip_all_tags followed by esc_attr for thumbnail descriptions. Include post and comment author names, which use the same entity-bearing values. Keep get_search_query() as WordPress returns it and escape the composed title with esc_html. Do not decode entities or use raw; Twig autoescaping remains on for other fields. Encoded tag-looking alt text stays inert text. Register the escaper metadata in standalone template harnesses and update the single golden whose unknown literal title tag is removed by KSES. Real WordPress fixtures verify permitted markup and sanitization separately. --- .../template-extension/page-post.html | 2 +- .github/scripts/attribute-helper-smoke.php | 2 + .github/scripts/fallback-navigation-smoke.php | 2 + .github/scripts/template-extension-smoke.php | 2 + .../scripts/wordpress-text-test-adapter.php | 48 +++++++++++++++++++ templates/layouts/base.twig | 4 +- templates/page.twig | 2 +- templates/partials/comment.twig | 2 +- templates/partials/footer.twig | 2 +- templates/partials/menu.twig | 2 +- templates/partials/tease.twig | 4 +- templates/single.twig | 6 +-- 12 files changed, 66 insertions(+), 12 deletions(-) create mode 100644 .github/scripts/wordpress-text-test-adapter.php diff --git a/.github/fixtures/template-extension/page-post.html b/.github/fixtures/template-extension/page-post.html index 4308d14..de77fbb 100644 --- a/.github/fixtures/template-extension/page-post.html +++ b/.github/fixtures/template-extension/page-post.html @@ -47,7 +47,7 @@ <article class="entry entry--page" id="post-42"> <header class="entry__header"> - <h1 class="entry__title">Post <title> & fixture</h1> + <h1 class="entry__title">Post & fixture</h1> </header> <div class="entry__content"> diff --git a/.github/scripts/attribute-helper-smoke.php b/.github/scripts/attribute-helper-smoke.php index b3185a1..9fdfe17 100644 --- a/.github/scripts/attribute-helper-smoke.php +++ b/.github/scripts/attribute-helper-smoke.php @@ -51,6 +51,8 @@ function emulsify_register_smoke_twig_functions( \Twig\Environment $environment, * @return void */ function emulsify_register_smoke_wordpress_twig_shims( \Twig\Environment $environment ): void { + require_once __DIR__ . '/wordpress-text-test-adapter.php'; + emulsify_register_wordpress_text_test_filters( $environment ); $environment->addFunction( new \Twig\TwigFunction( 'function', diff --git a/.github/scripts/fallback-navigation-smoke.php b/.github/scripts/fallback-navigation-smoke.php index a0cf461..0190ab6 100644 --- a/.github/scripts/fallback-navigation-smoke.php +++ b/.github/scripts/fallback-navigation-smoke.php @@ -20,6 +20,7 @@ } require_once $autoload; +require_once __DIR__ . '/wordpress-text-test-adapter.php'; // Only WordPress translation needs a stand-in outside the WordPress fixture. if ( ! function_exists( '__' ) ) { @@ -81,6 +82,7 @@ function emulsify_navigation_expect( bool $condition, string $message, array &$e } $environment->addFunction( new \Twig\TwigFunction( 'function', array( new \Timber\Twig(), 'exec_function' ) ) ); +emulsify_register_wordpress_text_test_filters( $environment ); $integration->extensions( $environment ); $hostile = '"><script>alert(1)</script>'; diff --git a/.github/scripts/template-extension-smoke.php b/.github/scripts/template-extension-smoke.php index e3a18a9..8ece358 100644 --- a/.github/scripts/template-extension-smoke.php +++ b/.github/scripts/template-extension-smoke.php @@ -21,6 +21,7 @@ } require_once $autoload; +require_once __DIR__ . '/wordpress-text-test-adapter.php'; $GLOBALS['emulsify_extension_repo_root'] = $repo_root; $GLOBALS['emulsify_extension_child'] = sys_get_temp_dir() . '/emulsify-template-extension-' . uniqid( '', true ); @@ -177,6 +178,7 @@ function emulsify_extension_expect( bool $condition, string $message, array &$er } $environment->addFunction( new \Twig\TwigFunction( 'function', array( new \Timber\Twig(), 'exec_function' ) ) ); $environment->addFunction( new \Twig\TwigFunction( 'action', 'do_action' ) ); + emulsify_register_wordpress_text_test_filters( $environment ); $integration->extensions( $environment ); $core = $environment->getExtension( \Twig\Extension\CoreExtension::class ); $core->setTimezone( 'UTC' ); diff --git a/.github/scripts/wordpress-text-test-adapter.php b/.github/scripts/wordpress-text-test-adapter.php new file mode 100644 index 0000000..c4a26d0 --- /dev/null +++ b/.github/scripts/wordpress-text-test-adapter.php @@ -0,0 +1,48 @@ +<?php +/** + * Conservative WordPress text adapters for standalone Twig wiring checks. + * + * These checks cannot load WordPress. Timber supplies the actual filter + * definitions and safety options; the callbacks below cover entity handling + * only. In particular, stripping every tag is not a KSES implementation: the + * full WordPress fixture verifies allowed markup, protocols and attributes. + * + * @package Emulsify + */ + +if ( ! function_exists( 'apply_filters' ) ) { + function apply_filters( string $hook, $value, ...$arguments ) { + return $value; + } +} + +if ( ! function_exists( 'wp_strip_all_tags' ) ) { + function wp_strip_all_tags( $value, bool $remove_breaks = false ): string { + $value = preg_replace( '@<(script|style)\b[^>]*?>.*?</\1>@si', '', (string) $value ); + $value = strip_tags( $value ); + return trim( $remove_breaks ? preg_replace( '/[\r\n\t ]+/', ' ', $value ) : $value ); + } +} + +/** + * Adds Timber's real filter metadata with conservative standalone callbacks. + * + * @param \Twig\Environment $environment Test Twig environment. + * @return void + */ +function emulsify_register_wordpress_text_test_filters( \Twig\Environment $environment ): void { + $encode = static function ( $value ): string { + return htmlspecialchars( (string) $value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8', false ); + }; + $callbacks = array( + 'esc_html' => $encode, + 'esc_attr' => $encode, + 'wp_kses_post' => static function ( $value ) use ( $encode ): string { + return $encode( strip_tags( (string) $value ) ); + }, + ); + $definitions = ( new \Timber\Twig() )->get_timber_escaper_filters(); + foreach ( $callbacks as $name => $callback ) { + $environment->addFilter( new \Twig\TwigFilter( $name, $callback, $definitions[ $name ]['options'] ?? array( 'is_safe' => array( 'html' ) ) ) ); + } +} diff --git a/templates/layouts/base.twig b/templates/layouts/base.twig index 11186a5..721a58d 100644 --- a/templates/layouts/base.twig +++ b/templates/layouts/base.twig @@ -51,7 +51,7 @@ <div {{ add_attributes(site_header_inner_attributes) }}> {% block header %} <a {{ add_attributes(site_header_brand_attributes) }}> - {{ site.name|default('') }} + {{ site.name|default('')|esc_html }} </a> {% if menu_items is not empty %} @@ -72,7 +72,7 @@ {% block page_title %} {% if title|default(null) %} <header {{ add_attributes(page_header_attributes) }}> - <h1 {{ add_attributes(page_header_title_attributes) }}>{{ title }}</h1> + <h1 {{ add_attributes(page_header_title_attributes) }}>{{ title|esc_html }}</h1> </header> {% endif %} {% endblock %} diff --git a/templates/page.twig b/templates/page.twig index ba11867..6e90f6a 100644 --- a/templates/page.twig +++ b/templates/page.twig @@ -20,7 +20,7 @@ {% if post|default(null) %} <article {{ add_attributes(entry_attributes) }}> <header {{ add_attributes(entry_header_attributes) }}> - <h1 {{ add_attributes(entry_title_attributes) }}>{{ post.title }}</h1> + <h1 {{ add_attributes(entry_title_attributes) }}>{{ post.title|wp_kses_post }}</h1> </header> <div {{ add_attributes(entry_content_attributes) }}> diff --git a/templates/partials/comment.twig b/templates/partials/comment.twig index c642bec..6f71193 100644 --- a/templates/partials/comment.twig +++ b/templates/partials/comment.twig @@ -21,7 +21,7 @@ <article {{ add_attributes(comment_attributes) }}> {% if comment.author.name|default(null) %} <header {{ add_attributes(comment_header_attributes) }}> - <h3 {{ add_attributes(comment_author_attributes) }}>{{ comment.author.name }}</h3> + <h3 {{ add_attributes(comment_author_attributes) }}>{{ comment.author.name|esc_html }}</h3> </header> {% endif %} diff --git a/templates/partials/footer.twig b/templates/partials/footer.twig index 4158fe2..81ecde1 100644 --- a/templates/partials/footer.twig +++ b/templates/partials/footer.twig @@ -10,6 +10,6 @@ <footer {{ add_attributes(site_footer_attributes) }}> <p {{ add_attributes(site_footer_copyright_attributes) }}> - © {{ 'now'|date('Y') }} {{ site.name|default('') }} + © {{ 'now'|date('Y') }} {{ site.name|default('')|esc_html }} </p> </footer> diff --git a/templates/partials/menu.twig b/templates/partials/menu.twig index a2be383..f766bfa 100644 --- a/templates/partials/menu.twig +++ b/templates/partials/menu.twig @@ -22,7 +22,7 @@ } %} <li {{ add_attributes(item_attributes) }}> <a {{ add_attributes(link_attributes) }}> - {{ item.title|default('') }} + {{ item.title|default('')|esc_html }} </a> {% if children is not empty %} diff --git a/templates/partials/tease.twig b/templates/partials/tease.twig index d4e7971..3b5bd40 100644 --- a/templates/partials/tease.twig +++ b/templates/partials/tease.twig @@ -18,14 +18,14 @@ {% block media %} {% if post.thumbnail|default(null) and post.thumbnail.src|default(null) %} <div {{ add_attributes(teaser_media_attributes) }}> - <img src="{{ post.thumbnail.src }}" alt="{{ post.thumbnail.alt|default(post.title) }}" /> + <img src="{{ post.thumbnail.src }}" alt="{{ function('wp_strip_all_tags', post.thumbnail.alt|default(post.title))|esc_attr }}" /> </div> {% endif %} {% endblock %} {% block title %} <h2 {{ add_attributes(teaser_title_attributes) }}> - <a href="{{ post.link|default('#') }}">{{ post.title|default(function('__', 'Untitled', 'emulsify')) }}</a> + <a href="{{ post.link|default('#') }}">{{ post.title|default(function('__', 'Untitled', 'emulsify'))|wp_kses_post }}</a> </h2> {% endblock %} diff --git a/templates/single.twig b/templates/single.twig index c12760b..e12e9e2 100644 --- a/templates/single.twig +++ b/templates/single.twig @@ -41,18 +41,18 @@ <article {{ add_attributes(entry_attributes) }}> {% if post.thumbnail|default(null) and post.thumbnail.src|default(null) %} <figure {{ add_attributes(entry_media_attributes) }}> - <img src="{{ post.thumbnail.src|resize(1200, 675) }}" alt="{{ post.thumbnail.alt|default(post.title) }}" /> + <img src="{{ post.thumbnail.src|resize(1200, 675) }}" alt="{{ function('wp_strip_all_tags', post.thumbnail.alt|default(post.title))|esc_attr }}" /> </figure> {% endif %} <header {{ add_attributes(entry_header_attributes) }}> - <h1 {{ add_attributes(entry_title_attributes) }}>{{ post.title }}</h1> + <h1 {{ add_attributes(entry_title_attributes) }}>{{ post.title|wp_kses_post }}</h1> {% if post.author|default(null) or post.date|default(null) %} <div {{ add_attributes(entry_meta_attributes) }}> {% if post.author|default(null) %} <span {{ add_attributes(entry_author_attributes) }}> - <a href="{{ post.author.path|default('#') }}">{{ post.author.name }}</a> + <a href="{{ post.author.path|default('#') }}">{{ post.author.name|esc_html }}</a> </span> {% endif %} From dd200fb13670ca4d2c1d5f245a103b35617ca1e8 Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Thu, 29 Oct 2026 00:13:42 -0500 Subject: [PATCH 056/100] test(templates): check visible text for titles, names and searches Reproduce literal entities in DOM text before the template fix, then cover posts, pages, listings, category and author archives, searches, site/menu and comment names, and thumbnail alt fallbacks. Verify real WordPress KSES keeps safe title markup and removes event handlers and unsafe URL protocols. Exercise raw and encoded hostile text and explicit thumbnail descriptions; restore fixture state before the existing accessibility audit. Standalone Twig checks cover entity wiring with documented conservative adapters. --- .github/scripts/formatted-text-dom-smoke.php | 60 ++++++++ .github/scripts/template-extension-smoke.php | 62 +++++++- .github/scripts/wordpress-fixture-smoke.cjs | 150 +++++++++++++++++++ 3 files changed, 269 insertions(+), 3 deletions(-) create mode 100644 .github/scripts/formatted-text-dom-smoke.php diff --git a/.github/scripts/formatted-text-dom-smoke.php b/.github/scripts/formatted-text-dom-smoke.php new file mode 100644 index 0000000..28d6b16 --- /dev/null +++ b/.github/scripts/formatted-text-dom-smoke.php @@ -0,0 +1,60 @@ +<?php +/** + * Inspect actual DOM text and attribute values in the WordPress HTTP fixture. + * + * @package Emulsify + */ + +$fixture = json_decode( file_get_contents( 'php://stdin' ), true, 512, JSON_THROW_ON_ERROR ); +$document = new DOMDocument(); +libxml_use_internal_errors( true ); +$document->loadHTML( '<?xml encoding="UTF-8">' . $fixture['html'] ); +libxml_clear_errors(); +$xpath = new DOMXPath( $document ); + +foreach ( $fixture['assertions'] as $assertion ) { + $nodes = $xpath->query( $assertion['xpath'] ); + if ( false === $nodes || 0 === $nodes->length ) { + throw new RuntimeException( $fixture['name'] . ': missing ' . $assertion['xpath'] ); + } + foreach ( $nodes as $node ) { + $actual = isset( $assertion['attribute'] ) ? $node->getAttribute( $assertion['attribute'] ) : trim( $node->textContent ); + if ( isset( $assertion['contains'] ) ) { + $matches = str_contains( $actual, $assertion['contains'] ); + } else { + $matches = $actual === $assertion['text']; + } + if ( ! $matches ) { + throw new RuntimeException( $fixture['name'] . ': ' . $assertion['xpath'] . ' visible value was ' . json_encode( $actual ) . '; expected ' . json_encode( $assertion['text'] ?? $assertion['contains'] ) ); + } + if ( preg_match( '/&(?:amp|quot|lt|gt|#\d+|#x[0-9a-f]+);/i', $actual ) ) { + throw new RuntimeException( $fixture['name'] . ': literal HTML entity in DOM value.' ); + } + $allowed = $assertion['elements'] ?? array(); + foreach ( $node->getElementsByTagName( '*' ) as $element ) { + if ( ! in_array( strtolower( $element->tagName ), $allowed, true ) ) { + throw new RuntimeException( $fixture['name'] . ': unexpected ' . $element->tagName . ' inside ' . $assertion['xpath'] ); + } + foreach ( $element->attributes as $attribute ) { + if ( str_starts_with( strtolower( $attribute->name ), 'on' ) ) { + throw new RuntimeException( $fixture['name'] . ': executable event attribute survived.' ); + } + if ( in_array( strtolower( $attribute->name ), array( 'src', 'href' ), true ) && preg_match( '/^\s*(?:javascript|vbscript|data):/i', $attribute->value ) ) { + throw new RuntimeException( $fixture['name'] . ': unsafe URL protocol survived.' ); + } + } + } + if ( isset( $assertion['elementCount'] ) && $node->getElementsByTagName( '*' )->length !== $assertion['elementCount'] ) { + throw new RuntimeException( $fixture['name'] . ': safe inline title markup was lost.' ); + } + if ( isset( $assertion['attribute'] ) ) { + foreach ( $node->attributes as $attribute ) { + if ( ! in_array( $attribute->name, array( 'src', 'alt' ), true ) ) { + throw new RuntimeException( $fixture['name'] . ': image attribute breakout: ' . $attribute->name ); + } + } + } + } +} + +echo "DOM text and attributes passed.\n"; diff --git a/.github/scripts/template-extension-smoke.php b/.github/scripts/template-extension-smoke.php index 8ece358..25f512f 100644 --- a/.github/scripts/template-extension-smoke.php +++ b/.github/scripts/template-extension-smoke.php @@ -15,8 +15,8 @@ $repo_root = dirname( __DIR__, 2 ); $autoload = $repo_root . '/vendor/autoload.php'; -if ( ! is_readable( $autoload ) ) { - fwrite( STDERR, "Composer dependencies are required for the template extension smoke test.\n" ); +if ( ! is_readable( $autoload ) || ! class_exists( DOMDocument::class ) ) { + fwrite( STDERR, "Composer dependencies and PHP DOM are required for the template extension smoke test.\n" ); exit( 1 ); } @@ -179,6 +179,8 @@ function emulsify_extension_expect( bool $condition, string $message, array &$er $environment->addFunction( new \Twig\TwigFunction( 'function', array( new \Timber\Twig(), 'exec_function' ) ) ); $environment->addFunction( new \Twig\TwigFunction( 'action', 'do_action' ) ); emulsify_register_wordpress_text_test_filters( $environment ); + $environment->addFilter( new \Twig\TwigFilter( 'resize', static function ( $value ) { return $value; } ) ); + $environment->addFilter( new \Twig\TwigFilter( 'wpautop', static function ( $value ) { return $value; }, array( 'is_safe' => array( 'html' ) ) ) ); $integration->extensions( $environment ); $core = $environment->getExtension( \Twig\Extension\CoreExtension::class ); $core->setTimezone( 'UTC' ); @@ -212,7 +214,61 @@ static function ( $date, $format = null, $timezone = null ) use ( $core ): strin emulsify_extension_golden( $has_post ? 'page-post' : 'page-no-post', $environment->render( '@emulsify-tpl/page.twig', $context ), $capture ); } - $errors = array(); + $errors = array(); + // These supplied entities model WordPress output; real texturize/KSES lives in the WordPress fixture. + $formatted_title = 'Don’t “panic” & relax'; + $visible_title = 'Don’t “panic” & relax'; + $text_context = emulsify_extension_context( true, true, false ); + $text_context['site']['name'] = 'Ben & Jerry's'; + $text_context['title'] = 'Search results for Tom & Jerry "live"'; + $text_context['menu']['get_items'][0]['title'] = $formatted_title; + $text_context['post'] = array( + 'id' => 42, + 'title' => $formatted_title, + 'content' => '', + 'link' => '/formatted/', + 'author' => array( 'name' => 'Rock 'n' Roll & Blues', 'path' => '/author/' ), + 'thumbnail' => array( 'src' => '/thumbnail.jpg', 'alt' => '' ), + 'comments' => array( array( 'id' => 1, 'author' => array( 'name' => 'Ben & Jerry's' ) ) ), + ); + $dom = static function ( string $html ): DOMXPath { + $document = new DOMDocument(); + $previous = libxml_use_internal_errors( true ); + $document->loadHTML( '<!doctype html><html><head><meta charset="UTF-8"></head><body>' . $html . '</body></html>' ); + libxml_clear_errors(); + libxml_use_internal_errors( $previous ); + return new DOMXPath( $document ); + }; + foreach ( array( 'page.twig', 'single.twig', 'partials/tease.twig' ) as $text_template ) { + $text_output = $environment->render( '@emulsify-tpl/' . $text_template, $text_context ); + $text_dom = $dom( $text_output ); + $post_titles = $text_dom->query( '//h1[contains(@class, "entry__title")]|//h2[contains(@class, "teaser__title")]/a' ); + emulsify_extension_expect( 1 === $post_titles->length && $visible_title === trim( $post_titles->item( 0 )->textContent ), $text_template . ' must show WordPress-formatted post titles as visible characters once.', $errors ); + if ( 'page.twig' !== $text_template ) { + $image = $text_dom->query( '//img' )->item( 0 ); + emulsify_extension_expect( null !== $image && $visible_title === $image->getAttribute( 'alt' ), $text_template . ' must use the post title as an alt fallback without literal entities.', $errors ); + } + if ( 'single.twig' === $text_template ) { + $author = $text_dom->query( '//span[contains(@class, "entry__author")]/a' )->item( 0 ); + emulsify_extension_expect( null !== $author && "Rock 'n' Roll & Blues" === trim( $author->textContent ), 'Post author names must show visible punctuation and ampersands once.', $errors ); + $comment_author = $text_dom->query( '//h3[contains(@class, "comment__author")]' )->item( 0 ); + emulsify_extension_expect( null !== $comment_author && "Ben & Jerry's" === trim( $comment_author->textContent ), 'Comment author names must show visible punctuation and ampersands once.', $errors ); + } + } + $text_dom = $dom( $environment->render( 'content-fixture.twig', $text_context ) ); + foreach ( array( '//a[contains(@class, "site-header__brand")]' => "Ben & Jerry's", '//footer//p' => "© 2026 Ben & Jerry's", '//h1[contains(@class, "page-header__title")]' => 'Search results for Tom & Jerry "live"', '//nav//a[@aria-current="page"]' => $visible_title ) as $query => $visible ) { + $node = $text_dom->query( $query )->item( 0 ); + emulsify_extension_expect( null !== $node && $visible === trim( $node->textContent ), 'WordPress-formatted text must remain readable in ' . $query . '.', $errors ); + } + foreach ( array( '<em>Raw</em> & "alt"' => 'Raw & "alt"', '<em>Encoded</em> & "alt"' => '<em>Encoded</em> & "alt"', '<strong>Helpful</strong><script>alert(1)</script><img src=x onerror=alert(1)> & "quoted"' => 'Helpful & "quoted"' ) as $alt => $plain_alt ) { + $text_context['post']['thumbnail']['alt'] = $alt; + foreach ( array( 'single.twig', 'partials/tease.twig' ) as $text_template ) { + $image_dom = $dom( $environment->render( '@emulsify-tpl/' . $text_template, $text_context ) ); + $image = $image_dom->query( '//img' )->item( 0 ); + emulsify_extension_expect( null !== $image && $plain_alt === $image->getAttribute( 'alt' ), $text_template . ' must strip literal alt markup, keep encoded markup inert, and show entities once.', $errors ); + emulsify_extension_expect( 1 === $image_dom->query( '//img' )->length && 0 === $image_dom->query( '//script|//*[@onerror]' )->length, $text_template . ' alt text must never introduce elements or event-handler attributes.', $errors ); + } + } foreach ( array( 'tease.twig', 'tease-post.twig' ) as $teaser_template ) { foreach ( array( 'manual', 'automatic' ) as $excerpt_source ) { foreach ( array( false, true ) as $protected ) { diff --git a/.github/scripts/wordpress-fixture-smoke.cjs b/.github/scripts/wordpress-fixture-smoke.cjs index 8a69c68..def48d7 100755 --- a/.github/scripts/wordpress-fixture-smoke.cjs +++ b/.github/scripts/wordpress-fixture-smoke.cjs @@ -1017,6 +1017,155 @@ add_filter( 'timber/post/content/password_form', function ( $form ) { } } +async function checkFormattedText(wpPath, baseUrl) { + const values = { + title: 'Don\'t "panic" & relax', + category: "Rock 'n' Roll & Blues", + site: "Ben & Jerry's", + author: "Writer & Reader's", + search: 'Tom & Jerry "live"', + }; + const fixture = JSON.parse(wp(wpPath, ['eval', String.raw` +$values = json_decode( file_get_contents( 'php://stdin' ), true ); +$fixture = array( + 'blogname' => get_option( 'blogname' ), + 'locations' => get_theme_mod( 'nav_menu_locations', array() ), +); +$fixture['author'] = wp_insert_user( array( 'user_login' => 'formatted-text-fixture', 'user_pass' => wp_generate_password(), 'display_name' => $values['author'], 'role' => 'author' ) ); +$category = wp_insert_term( $values['category'], 'category' ); +$fixture['category'] = $category['term_id']; +foreach ( array( 'post', 'page' ) as $type ) { + $fixture[$type] = wp_insert_post( wp_slash( array( 'post_type' => $type, 'post_status' => 'publish', 'post_title' => $values['title'], 'post_content' => 'FormattedTextFixture content', 'post_author' => $fixture['author'], 'post_category' => array( $fixture['category'] ) ) ) ); +} +$fixture['comment'] = wp_insert_comment( array( 'comment_post_ID' => $fixture['post'], 'comment_author' => 'Reader & Friend’s', 'comment_content' => 'Formatted text comment', 'comment_approved' => 1 ) ); +$fixture['menu'] = wp_create_nav_menu( 'Formatted text fixture' ); +$fixture['menuItem'] = wp_update_nav_menu_item( $fixture['menu'], 0, array( 'menu-item-object-id' => $fixture['post'], 'menu-item-object' => 'post', 'menu-item-type' => 'post_type', 'menu-item-status' => 'publish' ) ); +set_theme_mod( 'nav_menu_locations', array( 'primary' => $fixture['menu'] ) ); +update_option( 'blogname', $values['site'] ); +// A tiny local image exercises the actual Timber thumbnail and its title fallback. +$upload = wp_upload_bits( 'formatted-text.png', null, base64_decode( 'iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91JpzAAAACXBIWXMAAA7EAAAOxAGVKw4bAAAAC0lEQVQImWNgQAYAAA4AAbGa6gYAAAAASUVORK5CYII=' ) ); +$fixture['attachment'] = wp_insert_attachment( array( 'post_mime_type' => 'image/png', 'post_title' => 'Formatted text image', 'post_status' => 'inherit' ), $upload['file'] ); +require_once ABSPATH . 'wp-admin/includes/image.php'; +wp_update_attachment_metadata( $fixture['attachment'], wp_generate_attachment_metadata( $fixture['attachment'], $upload['file'] ) ); +set_post_thumbnail( $fixture['post'], $fixture['attachment'] ); +update_option( 'emulsify_fixture_text_ids', array( $fixture['post'], $fixture['page'] ) ); +$decode = static function ( $value ) { return html_entity_decode( $value, ENT_QUOTES | ENT_HTML5, 'UTF-8' ); }; +$fixture['expected'] = array( + 'title' => $decode( get_the_title( $fixture['post'] ) ), + 'site' => $decode( ( new \Timber\Site() )->name ), + 'category' => $decode( apply_filters( 'single_cat_title', get_term( $fixture['category'], 'category' )->name ) ), + 'author' => $decode( \Timber\Timber::get_user( $fixture['author'] )->name() ), + 'comment' => $decode( get_comment( $fixture['comment'] )->comment_author ), +); +echo wp_json_encode( $fixture ); +`], { input: JSON.stringify(values) })); + const plugin = path.join(wpPath, 'wp-content', 'mu-plugins', 'emulsify-formatted-text-fixture.php'); + const inspect = (name, html, assertions) => run(`Inspect ${name} DOM`, 'php', [path.join(repoRoot, '.github/scripts/formatted-text-dom-smoke.php')], { + input: JSON.stringify({ name, html, assertions }), + }); + const request = (name, route) => checkRoute(baseUrl, { name, path: route, includes: [] }); + const common = (site, menu) => [ + { xpath: '//a[@rel="home"]', text: site }, + { xpath: '//footer/p', contains: site }, + { xpath: `//nav//a[contains(@href, '?p=${fixture.post}')]`, text: menu }, + ]; + const postTitle = (id, text, extra = {}) => ({ xpath: `//*[@id="post-${id}"]//h1`, text, ...extra }); + const teaserTitle = (text, extra = {}) => ({ xpath: `//*[@id="teaser-${fixture.post}"]//h2/a`, text, ...extra }); + const imageAlt = (singular, text) => ({ xpath: `//*[@id="${singular ? 'post' : 'teaser'}-${fixture.post}"]//*[contains(concat(' ', normalize-space(@class), ' '), ' ${singular ? 'entry' : 'teaser'}__media ')]/img`, attribute: 'alt', text }); + try { + const expected = fixture.expected; + for (const [type, query] of [['post', 'p'], ['page', 'page_id']]) { + const body = await request(`formatted ${type}`, `/?${query}=${fixture[type]}`); + const assertions = [...common(expected.site, expected.title), postTitle(fixture[type], expected.title)]; + if (type === 'post') assertions.push( + { xpath: `//*[@id="post-${fixture.post}"]//*[contains(@class, 'entry__author')]/a`, text: expected.author }, + { xpath: `//*[@id="comment-${fixture.comment}"]//h3`, text: expected.comment }, + imageAlt(true, expected.title), + ); + inspect(`formatted ${type}`, body, assertions); + } + for (const [name, route, heading] of [ + ['blog', '/', null], + ['category', `/?cat=${fixture.category}`, expected.category], + ['author', `/?author=${fixture.author}`, `Archive of ${expected.author}`], + ]) { + const body = await request(`formatted ${name}`, route); + const assertions = [...common(expected.site, expected.title), teaserTitle(expected.title), imageAlt(false, expected.title)]; + if (heading) assertions.push({ xpath: '//main/header/h1', text: heading }); + inspect(`formatted ${name}`, body, assertions); + } + const search = await request('formatted search', `/?s=${encodeURIComponent(values.search)}`); + inspect('formatted search', search, [...common(expected.site, expected.title), { xpath: '//main/header/h1', text: `Search results for ${values.search}` }]); + + // Inject values after WordPress's save-time sanitization to model plugin + // title/name filters, including both raw and already encoded input. + fs.writeFileSync(plugin, `<?php +function emulsify_fixture_text_value( $key, $fallback ) { + $values = get_option( 'emulsify_fixture_text_values', array() ); + return isset( $values[$key] ) ? $values[$key] : $fallback; +} +add_filter( 'the_title', function ( $title, $id ) { + return in_array( (int) $id, get_option( 'emulsify_fixture_text_ids', array() ), true ) ? emulsify_fixture_text_value( 'title', $title ) : $title; +}, 999, 2 ); +add_filter( 'nav_menu_item_title', function ( $title ) { return emulsify_fixture_text_value( 'name', $title ); }, 999 ); +add_filter( 'option_blogname', function ( $name ) { return emulsify_fixture_text_value( 'name', $name ); }, 999 ); +add_filter( 'timber/user/name', function ( $name ) { return emulsify_fixture_text_value( 'name', $name ); }, 999 ); +add_filter( 'single_cat_title', function ( $name ) { return emulsify_fixture_text_value( 'name', $name ); }, 999 ); +`); + const safeTitle = '<strong>Bold</strong> & <em>calm</em> "quotes"'; + const hostile = '<script>alert("fixture")</script><img src="javascript:alert(1)" onerror=alert(1)> "quoted" & relaxed'; + for (const [name, title, plainName, inlineCount] of [ + ['safe inline title', safeTitle, hostile, 2], + ['hostile raw title', hostile, hostile, null], + ['hostile encoded title', '<script>alert("fixture")</script> & calm', '<img src=x onerror=alert(1)> "quoted" & relaxed', null], + ]) { + wp(wpPath, ['option', 'update', 'emulsify_fixture_text_values', JSON.stringify({ title, name: plainName }), '--format=json']); + const expected = JSON.parse(wp(wpPath, ['eval', String.raw` +$values = get_option( 'emulsify_fixture_text_values' ); +$decode = static function ( $value ) { return html_entity_decode( $value, ENT_QUOTES | ENT_HTML5, 'UTF-8' ); }; +echo wp_json_encode( array( 'title' => $decode( wp_strip_all_tags( wp_kses_post( $values['title'] ) ) ), 'name' => $decode( $values['name'] ), 'alt' => $decode( wp_strip_all_tags( $values['title'] ) ) ) ); +`])); + const titleOptions = { elements: ['strong', 'em', 'img'], ...(inlineCount === null ? {} : { elementCount: inlineCount }) }; + const post = await request(name, `/?p=${fixture.post}`); + inspect(name, post, [...common(expected.name, expected.name), postTitle(fixture.post, expected.title, titleOptions), imageAlt(true, expected.alt), + { xpath: `//*[@id="post-${fixture.post}"]//*[contains(@class, 'entry__author')]/a`, text: expected.name }, + ]); + const page = await request(`${name} page`, `/?page_id=${fixture.page}`); + inspect(`${name} page`, page, [postTitle(fixture.page, expected.title, titleOptions)]); + const archive = await request(`${name} category`, `/?cat=${fixture.category}`); + inspect(`${name} category`, archive, [teaserTitle(expected.title, titleOptions), imageAlt(false, expected.alt), { xpath: '//main/header/h1', text: expected.name }]); + } + wp(wpPath, ['option', 'delete', 'emulsify_fixture_text_values']); + for (const [altName, alt, expectedAlt] of [ + ['raw', '<script>alert("fixture")</script><style>body{display:none}</style><img src=x onerror=alert(1)> Portrait "quoted" & relaxed', 'Portrait "quoted" & relaxed'], + ['encoded', '<script>alert(1)</script> Portrait & "quoted"', '<script>alert(1)</script> Portrait & "quoted"'], + ]) { + wp(wpPath, ['post', 'meta', 'update', String(fixture.attachment), '_wp_attachment_image_alt', alt]); + for (const [name, route, singular] of [['single', `/?p=${fixture.post}`, true], ['blog', '/', false]]) { + const body = await request(`explicit ${altName} thumbnail alt ${name}`, route); + inspect(`explicit ${altName} thumbnail alt ${name}`, body, [imageAlt(singular, expectedAlt)]); + } + } + log('WordPress formatted text: native title/name/search characters render once; safe title markup survives; hostile markup and thumbnail attributes remain safe.'); + } finally { + fs.rmSync(plugin, { force: true }); + wp(wpPath, ['eval', String.raw` +$fixture = json_decode( file_get_contents( 'php://stdin' ), true ); +delete_option( 'emulsify_fixture_text_ids' ); +delete_option( 'emulsify_fixture_text_values' ); +update_option( 'blogname', $fixture['blogname'] ); +set_theme_mod( 'nav_menu_locations', $fixture['locations'] ); +wp_delete_nav_menu( $fixture['menu'] ); +wp_delete_post( $fixture['post'], true ); +wp_delete_post( $fixture['page'], true ); +wp_delete_attachment( $fixture['attachment'], true ); +wp_delete_term( $fixture['category'], 'category' ); +require_once ABSPATH . 'wp-admin/includes/user.php'; +wp_delete_user( $fixture['author'] ); +`], { input: JSON.stringify(fixture) }); + } +} + async function renderRoutes(wpPath, content, baseUrl, port, themeSlug) { startServer(wpPath, port); await waitForServer(baseUrl); @@ -1068,6 +1217,7 @@ async function renderRoutes(wpPath, content, baseUrl, port, themeSlug) { await checkSearchPagination(baseUrl); await checkGeneratedAssets(baseUrl, themeSlug); await checkPasswordProtection(wpPath, content, baseUrl, path.join(wpPath, 'wp-content', 'themes', themeSlug)); + await checkFormattedText(wpPath, baseUrl); if (auditAccessibility) { const archiveCount = Number(wp(wpPath, ['post', 'list', '--post_type=post', '--post_status=publish', `--cat=${content.categoryId}`, '--format=count'])); From a5c4d3f33a3aacd1a6cbea2cb7163b479eefaab0 Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Thu, 29 Oct 2026 00:13:52 -0500 Subject: [PATCH 057/100] docs: explain escaping WordPress-formatted text in Twig Describe which WordPress values carry entities and the appropriate Timber filters for markup, plain text and attributes. Record the child-override upgrade action and the corrected visible punctuation in release notes. --- UPGRADE.md | 8 ++++++++ docs/release-notes-next.md | 3 +++ docs/timber-and-twig-authoring.md | 34 +++++++++++++++++++++++++++++++ 3 files changed, 45 insertions(+) diff --git a/UPGRADE.md b/UPGRADE.md index 8ee2a67..0ead878 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -90,6 +90,14 @@ remain project-owned. New generated themes receive the updated defaults. WordPress's filtered password form and site URL. Review styles targeting the former custom form; core uses `.post-password-form`. See [password-protected template handling](docs/template-extension.md#password-protected-content). +7. **Use WordPress escapers for formatted text in child overrides.** Print + post/page titles in headings and links with `|wp_kses_post`; site names, + archive/search titles, menu titles, and author names with `|esc_html`. + Strip literal tags with `wp_strip_all_tags` before `|esc_attr` for alt text, + including title fallbacks. These filters avoid encoding WordPress entities + twice while retaining output safety. Keep Twig autoescaping enabled and do + not substitute `|raw`. See + [WordPress-formatted text](docs/timber-and-twig-authoring.md#wordpress-formatted-text). Render representative pages as both an administrator and an anonymous visitor, check the editor where blocks are used, and inspect `debug.log`. Storybook diff --git a/docs/release-notes-next.md b/docs/release-notes-next.md index 9749e0c..a04c464 100644 --- a/docs/release-notes-next.md +++ b/docs/release-notes-next.md @@ -70,6 +70,9 @@ lineage. See the [release process](release-process.md). ## Fixed +- Parent templates use WordPress escapers for formatted titles, site/menu and + author names, archive/search headings, and thumbnail alt text, so encoded + punctuation and ampersands display once without weakening Twig autoescaping. - Security: password-protected pages and listing excerpts no longer expose content without a valid post-password cookie, fixing an issue present in `2.0.0`. Timber content reads now show the password form, and protected pages diff --git a/docs/timber-and-twig-authoring.md b/docs/timber-and-twig-authoring.md index 1c4820a..797604f 100644 --- a/docs/timber-and-twig-authoring.md +++ b/docs/timber-and-twig-authoring.md @@ -146,6 +146,40 @@ Use `|raw` only for already-rendered, trusted WordPress HTML. Do not use it to p The `bem()` and `add_attributes()` helpers build and escape their own attribute values and are registered as safe HTML. Print them directly without `|raw`. +### WordPress-formatted text + +WordPress can return text that already contains HTML entities. Post and page +titles pass through `the_title`, which texturizes punctuation; site and term +names can be stored escaped; `get_search_query()` escapes its result by default. +Menu titles and author names can contain encoded characters too. Plain Twig +autoescaping would encode those entities again. + +Use Timber's WordPress escaper filters at the output boundary. They handle both +raw text and existing entities and tell Twig that the result is safe HTML: + +| Output | Filter | +| --- | --- | +| Post/page titles in headings and teaser links | `post.title\|wp_kses_post` | +| Site name, archive/search/author heading, menu title, author or comment-author name | `value\|esc_html` | +| Thumbnail alt text, including a post-title fallback | `function('wp_strip_all_tags', value)\|esc_attr` | + +`wp_kses_post` preserves WordPress's allowed post markup and removes disallowed +tags, event handlers, and unsafe URL protocols. Its allowlist is broader than +inline markup. `esc_html` displays tag-looking text literally, without creating +elements. Strip literal tags and script/style contents from alt text before +`esc_attr`; encoded tag-looking text remains inert text in the attribute. + +```twig +<h1>{{ post.title|wp_kses_post }}</h1> +<span>{{ site.name|esc_html }}</span> +<img src="{{ post.thumbnail.src }}" alt="{{ function('wp_strip_all_tags', post.thumbnail.alt|default(post.title))|esc_attr }}"> +``` + +The parent keeps `get_search_query()`'s default escaping and prints the composed +search title with `esc_html`. Do not decode these values or use `|raw` as a +double-escaping workaround. Keep ordinary autoescaping for ACF values, parsed +block data, and other strings that do not have a WordPress formatting contract. + ## Attribute helpers The parent theme registers Core-style helpers for class and attribute handling: From a32452fdf53c98ead397eb8cd50a78ccdfe41839 Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Thu, 29 Oct 2026 00:36:58 -0500 Subject: [PATCH 058/100] fix(media): fail closed per embed when the poster can't render Catch poster rendering failures, including absent Timber, without restoring the provider iframe. Return only an escaped video link in production; report through WordPress's diagnostic action and rethrow the original error in debug. Keep Embeds registered so opt-in admin and REST renders retain the privacy boundary without Timber. WordPress itself suppresses PHP diagnostic warnings outside debug; the doing_it_wrong_run action remains available in production. Cover real empty and invalid Twig overrides, plus isolated missing-Timber and non-debug cases without changing the fixture's global WP_DEBUG constant. --- .github/scripts/media-embed-failure-smoke.php | 88 ++++++++++++++++++ .github/scripts/media-embed-smoke.php | 55 ++++++++++++ includes/Media/Embeds.php | 89 ++++++++++--------- 3 files changed, 192 insertions(+), 40 deletions(-) create mode 100644 .github/scripts/media-embed-failure-smoke.php diff --git a/.github/scripts/media-embed-failure-smoke.php b/.github/scripts/media-embed-failure-smoke.php new file mode 100644 index 0000000..540de79 --- /dev/null +++ b/.github/scripts/media-embed-failure-smoke.php @@ -0,0 +1,88 @@ +<?php +/** + * Isolates absent Timber and WP_DEBUG constants for poster error handling. + * + * Minimal service doubles exercise only the failure boundary. The surrounding + * WordPress smoke separately exercises the real HTML API, Twig and diagnostics. + * + * @package Emulsify + */ + +namespace Timber { + if ( 'missing' !== ( $argv[2] ?? '' ) ) { + class Timber { + public static function compile( $template, $context ) { + if ( 'error' === $GLOBALS['media_failure'] ) { + throw new \LogicException( 'Broken child poster.' ); + } + return ''; + } + } + } +} + +namespace { + define( 'WP_DEBUG', '1' === ( $argv[3] ?? '' ) ); + $GLOBALS['media_failure'] = $argv[2] ?? ''; + $GLOBALS['media_diagnostics'] = 0; + + class WP_HTML_Tag_Processor { + private DOMElement $iframe; + public function __construct( string $html ) { + $document = new DOMDocument(); + $document->loadHTML( $html ); + $this->iframe = $document->getElementsByTagName( 'iframe' )->item( 0 ); + } + public function next_tag( $query = null ): bool { return true; } + public function has_self_closing_flag(): bool { return false; } + public function get_attribute( string $name ) { + return $this->iframe->hasAttribute( $name ) ? $this->iframe->getAttribute( $name ) : null; + } + } + function apply_filters( $hook, $value, ...$args ) { + return 'emulsify_theme_media_embed_options' === $hook ? array_merge( $value, array( 'click_to_load' => true, 'poster' => 'neutral' ) ) : $value; + } + function wp_parse_url( $url, $component = -1 ) { return parse_url( $url, $component ); } + function __( $text, $domain = '' ) { return $text; } + function get_bloginfo( $show ) { return 'en-US'; } + function is_rtl() { return false; } + function esc_url( $url ) { + return 'https' === parse_url( $url, PHP_URL_SCHEME ) ? htmlspecialchars( $url, ENT_QUOTES, 'UTF-8', false ) : ''; + } + function esc_html( $text ) { return htmlspecialchars( $text, ENT_QUOTES, 'UTF-8', false ); } + function _doing_it_wrong( $function, $message, $version ) { ++$GLOBALS['media_diagnostics']; } + function wp_trigger_error( $function, $message, $level = E_USER_NOTICE ) { ++$GLOBALS['media_diagnostics']; } + + require $argv[1]; + $title = 'Clip\' <img src=x onerror=alert(1)> & "quoted"'; + $url = 'https://www.youtube.com/watch?v=abcdefghijk&feature=fixture'; + $html = '<iframe src="https://www.youtube.com/embed/abcdefghijk" title="' . esc_html( $title ) . '"></iframe>'; + $thrown = null; + try { + $output = ( new \Emulsify\Theme\Media\Embeds() )->filter_html( $html, $url ); + } catch ( \Throwable $error ) { + $thrown = $error; + } + if ( 1 !== $GLOBALS['media_diagnostics'] ) { + throw new \RuntimeException( 'A poster failure must report exactly one WordPress diagnostic.' ); + } + if ( WP_DEBUG ) { + $expected = array( 'empty' => \UnexpectedValueException::class, 'error' => \LogicException::class, 'missing' => \RuntimeException::class )[ $GLOBALS['media_failure'] ]; + if ( ! $thrown instanceof $expected ) { + throw new \RuntimeException( 'Debug mode must rethrow the original poster failure.' ); + } + } else { + if ( null !== $thrown ) { + throw new \RuntimeException( 'Production poster failures must return a link, not throw.', 0, $thrown ); + } + $document = new \DOMDocument(); + $document->loadHTML( '<?xml encoding="utf-8">' . $output ); + $anchors = $document->getElementsByTagName( 'a' ); + if ( 1 !== $anchors->length || $url !== $anchors->item( 0 )->getAttribute( 'href' ) || $title !== $anchors->item( 0 )->textContent + || 0 !== $document->getElementsByTagName( 'iframe' )->length || 0 !== $document->getElementsByTagName( 'img' )->length + || 0 !== ( new \DOMXPath( $document ) )->query( '//@*[starts-with(name(), "on")]' )->length ) { + throw new \RuntimeException( 'The fallback must contain only the escaped video link and title, with no provider request.' ); + } + } + echo "PASS isolated poster failure.\n"; +} diff --git a/.github/scripts/media-embed-smoke.php b/.github/scripts/media-embed-smoke.php index 145989c..05f6af1 100644 --- a/.github/scripts/media-embed-smoke.php +++ b/.github/scripts/media-embed-smoke.php @@ -27,6 +27,13 @@ function emulsify_media_smoke_assert( bool $condition, string $message ): void { } } +/** Records independent regressions so the pre-fix run reports each problem. */ +function emulsify_media_smoke_check( bool $condition, string $message ): void { + if ( ! $condition ) { + $GLOBALS['emulsify_media_regressions'][] = $message; + } +} + /** * Gets an iframe attribute through WordPress's real HTML parser. * @@ -92,6 +99,7 @@ function emulsify_media_smoke_markup( string $source, string $title = 'Fixture v 'source' => 'https://player.vimeo.com/video/123456789?h=abc123&x.y=a%20b&x.y=second&list[]=one&list[]=two&dnt=0#chapter', ), ); +$GLOBALS['emulsify_media_regressions'] = array(); $service = new Embeds(); $canonical = 'https://vimeo.com/123456789/abc123'; $metadata_key = Embeds::METADATA_PREFIX . md5( $canonical ); @@ -383,6 +391,17 @@ function emulsify_media_smoke_markup( string $source, string $title = 'Fixture v $environment->removeCache( '@templates/partials/oembed-poster.twig' ); return $environment; }; + $diagnostics = 0; + $diagnostic = static function ( $function ) use ( &$diagnostics ): void { + if ( str_contains( $function, 'Embeds::filter_html' ) ) { + ++$diagnostics; + } + }; + $suppress_expected_notice = static function ( $trigger, $function ) { + return str_contains( $function, 'Embeds::filter_html' ) ? false : $trigger; + }; + add_action( 'doing_it_wrong_run', $diagnostic ); + add_filter( 'doing_it_wrong_trigger_error', $suppress_expected_notice, 999, 2 ); emulsify_media_smoke_assert( false !== file_put_contents( $override, '{# Intentionally empty project override. #}' ), 'Unable to write the temporary empty poster override.' ); add_filter( 'timber/twig', $refresh_template, 999 ); $empty_rejected = false; @@ -396,6 +415,38 @@ function emulsify_media_smoke_markup( string $source, string $title = 'Fixture v } emulsify_media_smoke_assert( $empty_rejected, 'An empty project poster must fail closed instead of silently loading a third-party player.' ); + // An invalid child Twig document must rethrow in the debug fixture, too. + file_put_contents( $override, '{% if %}' ); + add_filter( 'timber/twig', $refresh_template, 999 ); + $syntax_rejected = false; + try { + apply_filters( 'embed_oembed_html', emulsify_media_smoke_markup( $fixtures['youtube']['source'] ), $fixtures['youtube']['url'], array(), 0 ); + } catch ( \Twig\Error\SyntaxError $exception ) { + $syntax_rejected = true; + } finally { + remove_filter( 'timber/twig', $refresh_template, 999 ); + file_put_contents( $override, $custom ); + } + emulsify_media_smoke_check( $syntax_rejected, 'Invalid child Twig must rethrow its original error in debug mode.' ); + emulsify_media_smoke_check( 2 === $diagnostics, 'Both debug poster failures must emit a WordPress diagnostic before rethrowing.' ); + remove_action( 'doing_it_wrong_run', $diagnostic ); + remove_filter( 'doing_it_wrong_trigger_error', $suppress_expected_notice, 999 ); + // Isolate missing Timber and the non-debug constant without changing the + // fixture's WP_DEBUG setting or unloading its real Timber classes. + foreach ( array( 'empty', 'error', 'missing' ) as $failure ) { + foreach ( array( '0', '1' ) as $debug ) { + $process = proc_open( array( PHP_BINARY, __DIR__ . '/media-embed-failure-smoke.php', get_template_directory() . '/includes/Media/Embeds.php', $failure, $debug ), array( 1 => array( 'pipe', 'w' ), 2 => array( 'pipe', 'w' ) ), $pipes ); + emulsify_media_smoke_assert( is_resource( $process ), 'Unable to launch isolated poster failure smoke.' ); + $failure_output = stream_get_contents( $pipes[1] ) . stream_get_contents( $pipes[2] ); + fclose( $pipes[1] ); + fclose( $pipes[2] ); + emulsify_media_smoke_check( 0 === proc_close( $process ), 'Isolated ' . $failure . ' poster behavior with WP_DEBUG=' . $debug . ': ' . trim( $failure_output ) ); + } + } + if ( ! empty( $GLOBALS['emulsify_media_regressions'] ) ) { + throw new RuntimeException( "Media regressions:\n" . implode( "\n", array_unique( $GLOBALS['emulsify_media_regressions'] ) ) ); + } + echo "PASS media embeds: missing post IDs and no-post shortcodes, normalized public filter context, off defaults, raw parameter preservation, privacy, translated srcdoc posters with Arabic RTL and restored LTR locales, safe URLs, asynchronous metadata, classic/block filters, child overrides, and fail-closed poster errors.\n"; } finally { remove_filter( 'emulsify_theme_media_embed_options', $options_hook, 999 ); @@ -417,6 +468,10 @@ function emulsify_media_smoke_markup( string $source, string $title = 'Fixture v } else { set_transient( $metadata_key, $original_data, DAY_IN_SECONDS ); } + if ( isset( $diagnostic ) ) { + remove_action( 'doing_it_wrong_run', $diagnostic ); + remove_filter( 'doing_it_wrong_trigger_error', $suppress_expected_notice, 999 ); + } if ( null !== $original_file ) { file_put_contents( $override, $original_file ); } elseif ( is_file( $override ) ) { diff --git a/includes/Media/Embeds.php b/includes/Media/Embeds.php index 300a508..1df6e37 100644 --- a/includes/Media/Embeds.php +++ b/includes/Media/Embeds.php @@ -34,8 +34,9 @@ public function register(): void { * @param mixed $attr Embed attributes, normalized to an array. * @param mixed $post_id Positive post ID or numeric string, otherwise null. * @return mixed Rendered embed HTML, or the unchanged non-string input. - * @throws \RuntimeException When click-to-load rendering requires missing Timber. - * @throws \UnexpectedValueException When the poster template renders no document. + * @throws \RuntimeException In debug mode when Timber is missing. + * @throws \UnexpectedValueException In debug mode when the poster is empty. + * @throws \Throwable Rethrows other poster failures only in debug mode. */ public function filter_html( $html, $url = '', $attr = array(), $post_id = 0 ) { if ( ! is_string( $html ) ) { @@ -84,46 +85,54 @@ public function filter_html( $html, $url = '', $attr = array(), $post_id = 0 ) { $processor->set_attribute( 'src', $player_url ); } if ( $options['click_to_load'] ) { - if ( ! class_exists( '\Timber\Timber' ) ) { - throw new \RuntimeException( 'Timber is required to render click-to-load media embeds.' ); - } - $metadata = array(); - $thumbnail = ''; - if ( 'neutral' !== $options['poster'] ) { - if ( 'youtube' === $player['provider'] ) { - $thumbnail = 'https://i.ytimg.com/vi/' . $player['id'] . '/hqdefault.jpg'; - } else { - $metadata = $this->vimeo_metadata( $source, $player['id'] ); - $thumbnail = $metadata['thumbnail_url'] ?? ''; - } - } $title = $processor->get_attribute( 'title' ); $has_title = is_string( $title ) && '' !== trim( $title ); - $title = $has_title ? $title : ( $metadata['title'] ?? '' ); - if ( '' === trim( $title ) ) { - $title = 'youtube' === $player['provider'] ? __( 'YouTube video', 'emulsify' ) : __( 'Vimeo video', 'emulsify' ); - } - $language = get_bloginfo( 'language' ); - $context = array( - 'media_embed' => array( - 'player_url' => $this->set_parameter( $player_url, 'autoplay' ), - 'title' => $title, - 'provider' => 'youtube' === $player['provider'] ? 'YouTube' : 'Vimeo', - 'thumbnail_url' => $thumbnail, - 'language_code' => '' !== $language ? $language : 'en', - 'language_direction' => is_rtl() ? 'rtl' : 'ltr', - 'play_label' => __( 'Play video', 'emulsify' ), - ), - ); - $document = \Timber\Timber::compile( '@templates/partials/oembed-poster.twig', $context ); - if ( ! is_string( $document ) || '' === trim( $document ) ) { - throw new \UnexpectedValueException( 'The media embed poster template must render a non-empty HTML document.' ); - } - // Without src, browsers fall back to about:blank even without srcdoc support. - $processor->remove_attribute( 'src' ); - $processor->set_attribute( 'srcdoc', $this->srcdoc_value( $document ) ); - if ( ! $has_title ) { - $processor->set_attribute( 'title', $title ); + $title = $has_title ? $title : ( 'youtube' === $player['provider'] ? __( 'YouTube video', 'emulsify' ) : __( 'Vimeo video', 'emulsify' ) ); + try { + if ( ! class_exists( '\Timber\Timber' ) ) { + throw new \RuntimeException( 'Timber is required to render click-to-load media embeds.' ); + } + $metadata = array(); + $thumbnail = ''; + if ( 'neutral' !== $options['poster'] ) { + if ( 'youtube' === $player['provider'] ) { + $thumbnail = 'https://i.ytimg.com/vi/' . $player['id'] . '/hqdefault.jpg'; + } else { + $metadata = $this->vimeo_metadata( $source, $player['id'] ); + $thumbnail = $metadata['thumbnail_url'] ?? ''; + } + } + if ( ! $has_title && isset( $metadata['title'] ) && '' !== trim( $metadata['title'] ) ) { + $title = $metadata['title']; + } + $language = get_bloginfo( 'language' ); + $context = array( + 'media_embed' => array( + 'player_url' => $this->set_parameter( $player_url, 'autoplay' ), + 'title' => $title, + 'provider' => 'youtube' === $player['provider'] ? 'YouTube' : 'Vimeo', + 'thumbnail_url' => $thumbnail, + 'language_code' => '' !== $language ? $language : 'en', + 'language_direction' => is_rtl() ? 'rtl' : 'ltr', + 'play_label' => __( 'Play video', 'emulsify' ), + ), + ); + $document = \Timber\Timber::compile( '@templates/partials/oembed-poster.twig', $context ); + if ( ! is_string( $document ) || '' === trim( $document ) ) { + throw new \UnexpectedValueException( 'The media embed poster template must render a non-empty HTML document.' ); + } + // Without src, browsers fall back to about:blank even without srcdoc support. + $processor->remove_attribute( 'src' ); + $processor->set_attribute( 'srcdoc', $this->srcdoc_value( $document ) ); + if ( ! $has_title ) { + $processor->set_attribute( 'title', $title ); + } + } catch ( \Throwable $exception ) { + _doing_it_wrong( __METHOD__, 'The media embed poster could not render. Check Timber and the child poster template.', '2.1.0' ); + if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { + throw $exception; + } + return '<a href="' . esc_url( $url ) . '">' . esc_html( $title ) . '</a>'; } } $this->player_permissions( $processor ); From 9637f8a4291bd212110bd1678ca37c89f723b67a Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Thu, 29 Oct 2026 00:37:28 -0500 Subject: [PATCH 059/100] fix(media): keep srcdoc escaping exact on WordPress 6.8 On the probed esc_attr HTML API path, pre-encode only ampersands as numeric references. Preserve every byte of the child document instead of normalizing its existing numeric entities through another quote-escaping pass. Add a script-blocking poster meta CSP and real-core round trips for single- quoted child attributes and entity-bearing titles. The fixture no longer requires self in frame-src for its initial srcdoc document. --- .github/scripts/media-embed-smoke.php | 19 +++++++++++++++++++ .github/scripts/wordpress-fixture-smoke.cjs | 2 +- includes/Media/Embeds.php | 2 +- templates/partials/oembed-poster.twig | 1 + 4 files changed, 22 insertions(+), 2 deletions(-) diff --git a/.github/scripts/media-embed-smoke.php b/.github/scripts/media-embed-smoke.php index 05f6af1..281db84 100644 --- a/.github/scripts/media-embed-smoke.php +++ b/.github/scripts/media-embed-smoke.php @@ -236,6 +236,7 @@ function emulsify_media_smoke_markup( string $source, string $title = 'Fixture v emulsify_media_smoke_assert( 1 === $xpath->query( '//a//span[contains(@class, "visually-hidden")]' )->length, 'The complete video title must be visually hidden inside the accessible link label.' ); emulsify_media_smoke_assert( get_bloginfo( 'language' ) === $document->documentElement->getAttribute( 'lang' ), 'The poster document must declare the WordPress language.' ); emulsify_media_smoke_assert( 0 === $xpath->query( '//iframe | //script | //noscript | //@*[starts-with(name(), "on")]' )->length, 'Posters must contain no nested player, script, or injected event handler.' ); + emulsify_media_smoke_check( 1 === $xpath->query( '//meta[translate(@http-equiv,"ABCDEFGHIJKLMNOPQRSTUVWXYZ","abcdefghijklmnopqrstuvwxyz")="content-security-policy" and @content="script-src \'none\'"]' )->length, 'Parent posters must prohibit scripts with a meta CSP.' ); $images = $document->getElementsByTagName( 'img' ); emulsify_media_smoke_assert( ( 'provider' === $poster ? 1 : 0 ) === $images->length, 'Only provider posters may request thumbnails.' ); if ( 'provider' === $poster ) { @@ -379,11 +380,29 @@ function emulsify_media_smoke_markup( string $source, string $title = 'Fixture v $created_dirs[] = $directory; } } + // Numeric references inside a single-quoted child attribute must survive + // the outer srcdoc boundary unchanged, including on WordPress 6.8. + $roundtrip_title = "Clip' onfocus='alert(document.domain)' autofocus='"; + $roundtrip_template = '<!doctype html><html lang="en"><head><title>Roundtrip\' " A & B & ' <b>'; + $roundtrip = str_replace( '{{ media_embed.title }}', htmlspecialchars( $roundtrip_title, ENT_QUOTES, 'UTF-8' ), $roundtrip_template ); + file_put_contents( $override, $roundtrip_template ); + $options = array( 'click_to_load' => true, 'poster' => 'neutral' ); + $output = apply_filters( 'embed_oembed_html', emulsify_media_smoke_markup( $fixtures['youtube']['source'], $roundtrip_title ), $fixtures['youtube']['url'], array(), 0 ); + $actual_roundtrip = emulsify_media_smoke_attribute( $output, 'srcdoc' ); + emulsify_media_smoke_check( $roundtrip === $actual_roundtrip, 'srcdoc must round-trip every document byte, including numeric character references.' ); + $roundtrip_dom = emulsify_media_smoke_document( (string) $actual_roundtrip ); + emulsify_media_smoke_check( 0 === ( new DOMXPath( $roundtrip_dom ) )->query( '//@onfocus|//@autofocus' )->length, 'Single-quoted child attributes must not acquire injected handlers or autofocus.' ); + $clear_roundtrip = static function ( \Twig\Environment $environment ): \Twig\Environment { + $environment->removeCache( '@templates/partials/oembed-poster.twig' ); + return $environment; + }; + add_filter( 'timber/twig', $clear_roundtrip, 999 ); $custom = 'Child poster

        Child override

        '; emulsify_media_smoke_assert( false !== file_put_contents( $override, $custom ), 'Unable to write the temporary child poster override.' ); $options = array( 'click_to_load' => true, 'poster' => 'neutral' ); $output = apply_filters( 'embed_oembed_html', emulsify_media_smoke_markup( $fixtures['youtube']['source'] ), $fixtures['youtube']['url'], array(), 0 ); emulsify_media_smoke_assert( $custom === emulsify_media_smoke_attribute( $output, 'srcdoc' ), 'Child @templates poster overrides must replace the parent fallback.' ); + remove_filter( 'timber/twig', $clear_roundtrip, 999 ); // Invalidate the already-compiled child template after changing it in this // request. A normal request starts with the project's template already saved. diff --git a/.github/scripts/wordpress-fixture-smoke.cjs b/.github/scripts/wordpress-fixture-smoke.cjs index def48d7..549f6d9 100755 --- a/.github/scripts/wordpress-fixture-smoke.cjs +++ b/.github/scripts/wordpress-fixture-smoke.cjs @@ -616,7 +616,7 @@ add_filter( 'emulsify_theme_media_embed_options', function ( $options ) { } ); add_action( 'template_redirect', function () { if ( is_page( 'media-embeds' ) ) { - header( "Content-Security-Policy: frame-src 'self' https://www.youtube.com https://www.youtube-nocookie.com https://player.vimeo.com; img-src 'self' https://i.ytimg.com https://i.vimeocdn.com data:; style-src 'self' 'unsafe-inline'" ); + header( "Content-Security-Policy: frame-src https://www.youtube.com https://www.youtube-nocookie.com https://player.vimeo.com; img-src 'self' https://i.ytimg.com https://i.vimeocdn.com data:; style-src 'self' 'unsafe-inline'" ); header( 'Referrer-Policy: no-referrer' ); } } ); diff --git a/includes/Media/Embeds.php b/includes/Media/Embeds.php index 1df6e37..1d999bd 100644 --- a/includes/Media/Embeds.php +++ b/includes/Media/Embeds.php @@ -159,7 +159,7 @@ private function srcdoc_value( string $document ): string { $reader->next_tag(); $escapes_entities = '&' === $reader->get_attribute( 'srcdoc' ); } - return $escapes_entities ? $document : htmlspecialchars( $document, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8', true ); + return $escapes_entities ? $document : strtr( $document, array( '&' => '&' ) ); } /** diff --git a/templates/partials/oembed-poster.twig b/templates/partials/oembed-poster.twig index 04432c5..136ce2d 100644 --- a/templates/partials/oembed-poster.twig +++ b/templates/partials/oembed-poster.twig @@ -3,6 +3,7 @@ + {{ media_embed.title ?: media_embed.play_label }} Quotes \" and \' / café & 100%25% coverage.', + }, ]; function assert(condition, message) { @@ -96,6 +104,22 @@ function writeJson(filePath, data) { fs.writeFileSync(filePath, `${JSON.stringify(data, null, 2)}\n`); } +// Preserve JSON object/list identity and rewrite nested pattern namespaces. +function addMetadataFixtures(root) { + for (const relative of ['package.json', 'project.emulsify.json']) { + const file = path.join(root, relative); + const data = readJson(file); + data.generationFixture = { emptyObject: {}, emptyList: [] }; + writeJson(file, data); + } + writeJson(path.join(root, 'patterns/nested/fixture.json'), { + name: 'whisk/nested-fixture', + title: 'Nested fixture', + content: '

        Fixture

        ', + metadata: {}, + }); +} + /** * Generates through the in-site WP-CLI command. */ @@ -104,6 +128,10 @@ function generateWithWpCli(themeRoot, scenario) { // the child theme as a sibling of the parent theme. const parentDir = path.join(themeRoot, 'emulsify'); copyStarter(starterRoot, path.join(parentDir, 'whisk')); + addMetadataFixtures(path.join(parentDir, 'whisk')); + if (scenario.invalidPattern) { + fs.writeFileSync(path.join(parentDir, 'whisk/patterns/nested/fixture.json'), scenario.invalidPattern); + } fs.copyFileSync( path.join(repoRoot, 'style.css'), path.join(parentDir, 'style.css'), @@ -114,13 +142,19 @@ function generateWithWpCli(themeRoot, scenario) { [ path.join(__dirname, 'generation-parity-harness.php'), themeRoot, - scenario.displayName, - scenario.machineName, - scenario.description, + scenario.inputDisplayName || scenario.displayName, + scenario.inputMachineName || scenario.machineName, + scenario.inputDescription || scenario.description, ], { encoding: 'utf8' }, ); + if (scenario.invalidPattern) { + assert(result.status !== 0 && /valid JSON object: patterns[/\\]nested[/\\]fixture.json/.test(result.stderr), `WP-CLI must reject invalid nested pattern JSON with its path:\n${result.stderr}`); + assert(!fs.existsSync(path.join(themeRoot, scenario.machineName)), 'Invalid pattern JSON must not install a partially generated theme.'); + return; + } + assert( result.status === 0, `WP-CLI generation failed for ${scenario.machineName}:\n${ @@ -137,19 +171,30 @@ function generateWithWpCli(themeRoot, scenario) { function generateWithStarterInit(root, scenario) { const target = path.join(root, scenario.machineName); copyStarter(starterRoot, target); + addMetadataFixtures(target); + if (scenario.invalidPattern) { + fs.writeFileSync(path.join(target, 'patterns/nested/fixture.json'), scenario.invalidPattern); + } const project = readJson(path.join(target, 'project.emulsify.json')); - project.project.name = scenario.displayName; + project.project.name = scenario.inputDisplayName || scenario.displayName; project.project.machineName = scenario.machineName; - project.project.description = scenario.description; + project.project.description = scenario.inputDescription || scenario.description; writeJson(path.join(target, 'project.emulsify.json'), project); + const originalStyle = fs.readFileSync(path.join(target, 'style.css'), 'utf8'); const result = childProcess.spawnSync( process.execPath, [path.join(target, '.cli/init.js')], { cwd: target, encoding: 'utf8' }, ); + if (scenario.invalidPattern) { + assert(result.status !== 0 && /valid JSON object: patterns[/\\]nested[/\\]fixture.json/.test(result.stderr), `Starter init must reject invalid nested pattern JSON with its path:\n${result.stderr}`); + assert(fs.readFileSync(path.join(target, 'style.css'), 'utf8') === originalStyle, 'Invalid pattern JSON must fail before rewriting starter metadata.'); + return; + } + assert( result.status === 0, `Starter init generation failed for ${scenario.machineName}:\n${result.stderr || result.stdout}`, @@ -232,6 +277,14 @@ function compareTrees(scenario, wpCliDir, starterDir) { } function validate(scenario, themeDir, pathLabel) { + for (const relative of ['package.json', 'project.emulsify.json']) { + const fixture = readJson(path.join(themeDir, relative)).generationFixture; + assert(!Array.isArray(fixture.emptyObject), `${pathLabel} must preserve {} in ${relative}.`); + assert(Array.isArray(fixture.emptyList), `${pathLabel} must preserve [] in ${relative}.`); + } + const nested = readJson(path.join(themeDir, 'patterns/nested/fixture.json')); + assert(nested.name === `${scenario.machineName}/nested-fixture`, `${pathLabel} must rewrite nested pattern namespaces.`); + assert(!Array.isArray(nested.metadata), `${pathLabel} must preserve nested pattern JSON objects.`); const result = validateGeneratedTheme({ themeDir, machineName: scenario.machineName, @@ -281,6 +334,12 @@ try { ); } + for (const invalidPattern of ['{"name":', '[]', 'null']) { + const scenario = { machineName: 'invalid-json', displayName: 'Invalid JSON', description: 'Fixture.', invalidPattern }; + generateWithWpCli(fs.mkdtempSync(path.join(workRoot, 'invalid-wp-cli-')), scenario); + generateWithStarterInit(fs.mkdtempSync(path.join(workRoot, 'invalid-starter-')), scenario); + } + console.log('Generation parity smoke checks passed.'); } catch (error) { console.error(error.message); diff --git a/.github/scripts/wordpress-starter-init-smoke.cjs b/.github/scripts/wordpress-starter-init-smoke.cjs index 6be49bc..2025e88 100644 --- a/.github/scripts/wordpress-starter-init-smoke.cjs +++ b/.github/scripts/wordpress-starter-init-smoke.cjs @@ -226,6 +226,21 @@ const target = path.join(workRoot, 'acme-theme'); const hostileName = 'Acme */ echo 1; /* Theme'; try { + for (const invalid of ['Acme_Site', '../Acme', '---', 'café', 'İSite']) { + const rejectedTarget = path.join(workRoot, `invalid-${Buffer.from(invalid).toString('hex')}`); + copyStarterFixture(starterRoot, rejectedTarget); + const config = readJson(path.join(rejectedTarget, 'project.emulsify.json')); + config.project.name = 'Machine-name fixture'; + config.project.machineName = invalid; + writeJson(path.join(rejectedTarget, 'project.emulsify.json'), config); + const before = fs.readFileSync(path.join(rejectedTarget, 'style.css'), 'utf8'); + const rejected = childProcess.spawnSync(process.execPath, [path.join(rejectedTarget, '.cli/init.js')], { + cwd: rejectedTarget, encoding: 'utf8', + }); + assert(rejected.status !== 0 && /machineName.*lowercase|machineName.*canonical/i.test(rejected.stderr), `Invalid machineName ${invalid} must fail with naming guidance.`); + assert(fs.readFileSync(path.join(rejectedTarget, 'style.css'), 'utf8') === before, 'Invalid machineName must fail before rewriting metadata.'); + } + copyStarterFixture(starterRoot, target); writeJson(path.join(target, 'project.emulsify.json'), { diff --git a/UPGRADE.md b/UPGRADE.md index 6b6d80b..98e9d5a 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -137,6 +137,14 @@ remain project-owned. New generated themes receive the updated defaults. than arbitrary objects such as `WP_Post`. `CLASS` now merges into `class`. See [attribute rules](docs/timber-and-twig-authoring.md#attribute-helpers). +9. **Use canonical names with Emulsify CLI.** Supply a machine name such as + `acme-site`, using lowercase ASCII letters, digits and single hyphens. The + starter rejects `Acme_Site` before rewriting files because the CLI has already + chosen that directory. Rerun with `--machineName acme-site`; WP-CLI continues + normalizing its `--machine-name` option. Nested pattern JSON is now validated + and rewritten by both generators; fix malformed or non-object JSON before + generation. See the [generation contract](docs/generated-child-theme-contract.md). + Render representative pages as both an administrator and an anonymous visitor, check the editor where blocks are used, and inspect `debug.log`. Storybook alone does not exercise Timber context or WordPress hooks. diff --git a/docs/generated-child-theme-contract.md b/docs/generated-child-theme-contract.md index c3fffc0..880d722 100644 --- a/docs/generated-child-theme-contract.md +++ b/docs/generated-child-theme-contract.md @@ -11,6 +11,13 @@ Emulsify WordPress supports two ways to create a child theme: - **In-site WP-CLI.** `wp emulsify "Acme Site" --machine-name=acme-site` copies the bundled Whisk starter from the installed parent theme and applies targeted metadata updates. - **Standalone starter.** Emulsify CLI clones [`emulsify-wordpress-starter`](https://github.com/emulsify-ds/emulsify-wordpress-starter), installs dependencies, and runs the starter's `.cli/init.js` hook. +Machine names use lowercase ASCII letters, digits and single hyphens, with no +leading or trailing hyphen. WP-CLI normalizes `--machine-name=Acme_Site` to +`acme-site`. Emulsify CLI currently chooses its destination before the starter +hook runs, so pass `--machineName acme-site`; the hook rejects noncanonical +names with a suggested correction instead of renaming a directory behind the +CLI. Upstream CLI normalization must happen before directory creation. + These are separate implementations in different languages, so the release checks generate the same identities through both paths and require their file trees to match. The only expected difference is the lockfile the standalone path creates during `npm install`. ## What the contract guarantees @@ -34,7 +41,11 @@ These are separate implementations in different languages, so the release checks - Generation-only tooling and build output are absent, including `.cli`, `node_modules`, `dist`, `.out`, and `.coverage`. - Generated files are self-contained: no symbolic links, no documentation links that resolve outside the theme, and no script paths that point outside the theme except published packages under `node_modules/`. - Generated Markdown uses current terminology; retired Webpack and "subtheme" language fails the check. -- Both generation paths write JSON with two-space indentation, so generated metadata is byte-identical regardless of path. +- Both generation paths preserve JSON objects and lists, write two-space + indentation, and rewrite nested `patterns/` namespaces. Malformed or + non-object pattern JSON fails with its relative path before installation or + metadata rewrites. Display labels and descriptions receive WordPress-style + plain-text sanitization, including removal of percent-encoded octets. ## Outside the contract diff --git a/docs/release-notes-next.md b/docs/release-notes-next.md index b154a5a..534896c 100644 --- a/docs/release-notes-next.md +++ b/docs/release-notes-next.md @@ -77,6 +77,11 @@ lineage. See the [release process](release-process.md). ## Fixed +- Both child-theme generators preserve JSON object/list types, rewrite nested + patterns, sanitize metadata consistently, and stop on invalid JSON without + installing a partially processed starter. Standalone generation rejects + noncanonical machine names before changing files. + - Native robots, favicon, feed, trackback and sitemap responses remain available when Timber is missing, while theme pages retain a clear error. diff --git a/includes/Cli/GenerateChildThemeCommand.php b/includes/Cli/GenerateChildThemeCommand.php index e1978ad..7ef4cda 100644 --- a/includes/Cli/GenerateChildThemeCommand.php +++ b/includes/Cli/GenerateChildThemeCommand.php @@ -195,6 +195,7 @@ private function generate( array $args, array $assoc_args ): void { $staged = $this->apply_metadata_updates( $staging, $metadata_updates ); } } catch ( \Throwable $exception ) { + $staged = false; \WP_CLI::warning( sprintf( 'Could not finish staging the child theme: %s', $exception->getMessage() ) ); } @@ -313,9 +314,9 @@ function ( string $contents ) use ( $source_label, $machine_name, $parent, $desc $updates, $root, 'package.json', - function ( array $data ) use ( $machine_name, $description ): array { - $data['name'] = $machine_name; - $data['description'] = $this->one_line( $description ); + function ( \stdClass $data ) use ( $machine_name, $description ): \stdClass { + $data->name = $machine_name; + $data->description = $this->one_line( $description ); return $data; } ); @@ -324,18 +325,18 @@ function ( array $data ) use ( $machine_name, $description ): array { $updates, $root, 'project.emulsify.json', - function ( array $data ) use ( $theme_label, $machine_name, $version, $description ): array { - if ( ! isset( $data['project'] ) || ! is_array( $data['project'] ) ) { - $data['project'] = array(); + function ( \stdClass $data ) use ( $theme_label, $machine_name, $version, $description ): \stdClass { + if ( ! isset( $data->project ) || ! $data->project instanceof \stdClass ) { + $data->project = new \stdClass(); } // phpcs:ignore WordPress.WP.CapitalPDangit.MisspelledInText -- project.platform is a machine-readable adapter key. - $data['project']['platform'] = 'wordpress'; - $data['project']['name'] = $theme_label; - $data['project']['machineName'] = $machine_name; - $data['project']['generatedFrom'] = self::GENERATED_FROM; - $data['project']['generatedFromVersion'] = $version; - $data['project']['description'] = $this->one_line( $description ); + $data->project->platform = 'wordpress'; + $data->project->name = $theme_label; + $data->project->machineName = $machine_name; + $data->project->generatedFrom = self::GENERATED_FROM; + $data->project->generatedFromVersion = $version; + $data->project->description = $this->one_line( $description ); return $data; } @@ -433,24 +434,29 @@ private function collect_pattern_updates( array &$updates, string $root, string return; } - $files = glob( $pattern_dir . '/*.json' ); + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator( $pattern_dir, \RecursiveDirectoryIterator::SKIP_DOTS ) + ); + $files = array(); - if ( ! is_array( $files ) ) { - return; + foreach ( $iterator as $item ) { + if ( ! $item->isLink() && $item->isFile() && 'json' === $item->getExtension() ) { + $files[] = $item->getPathname(); + } } sort( $files ); foreach ( $files as $path ) { - $relative = 'patterns/' . basename( $path ); + $relative = $this->relative_path( $root, $path ); $this->collect_json_update( $updates, $root, $relative, - function ( array $data ) use ( $machine_name ): array { - if ( isset( $data['name'] ) && is_string( $data['name'] ) && 0 === strpos( $data['name'], self::STARTER_SLUG . '/' ) ) { - $data['name'] = $machine_name . '/' . substr( $data['name'], strlen( self::STARTER_SLUG ) + 1 ); + function ( \stdClass $data ) use ( $machine_name ): \stdClass { + if ( isset( $data->name ) && is_string( $data->name ) && 0 === strpos( $data->name, self::STARTER_SLUG . '/' ) ) { + $data->name = $machine_name . '/' . substr( $data->name, strlen( self::STARTER_SLUG ) + 1 ); } return $data; @@ -501,6 +507,8 @@ private function collect_text_update( array &$updates, string $root, string $rel * @param string $relative Relative file path. * @param callable $callback Data updater. * @return void + * + * @throws \RuntimeException When JSON is invalid or is not an object. */ private function collect_json_update( array &$updates, string $root, string $relative, callable $callback ): void { $path = $this->join_path( $root, $relative ); @@ -517,11 +525,11 @@ private function collect_json_update( array &$updates, string $root, string $rel return; } - $data = json_decode( $contents, true ); + $data = json_decode( $contents ); - if ( ! is_array( $data ) ) { - \WP_CLI::warning( sprintf( 'Could not decode starter JSON file: %s', $path ) ); - return; + if ( ! $data instanceof \stdClass ) { + // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Internal generator path, not rendered HTML. + throw new \RuntimeException( sprintf( 'Starter JSON file must contain a valid JSON object: %s', $relative ) ); } $updated_data = $callback( $data ); @@ -756,10 +764,10 @@ private function get_core_range( string $source ): string { * Whisk sources all use two. Re-indenting here keeps generated JSON identical * no matter which generation path a project used. * - * @param array $data Data to encode. + * @param \stdClass $data Data to encode. * @return string|null Encoded JSON, or NULL on failure. */ - private function encode_json( array $data ): ?string { + private function encode_json( \stdClass $data ): ?string { // JSON_UNESCAPED_UNICODE matches JSON.stringify, which never escapes // non-ASCII. Without it a non-ASCII theme name or description would make // the two generation paths emit different bytes. Do not add diff --git a/whisk/.cli/init.js b/whisk/.cli/init.js index 8664ad9..fb708b6 100644 --- a/whisk/.cli/init.js +++ b/whisk/.cli/init.js @@ -32,7 +32,18 @@ const writeTextIfChanged = (filePath, contents) => { } }; -const readJson = (filePath) => JSON.parse(readText(filePath)); +const readJson = (filePath) => { + let data; + try { + data = JSON.parse(readText(filePath)); + } catch (error) { + throw new Error(`Starter JSON file must contain a valid JSON object: ${path.relative(ROOT, filePath)}`, { cause: error }); + } + if (!isPlainObject(data)) { + throw new Error(`Starter JSON file must contain a valid JSON object: ${path.relative(ROOT, filePath)}`); + } + return data; +}; const writeJsonIfChanged = (filePath, data) => { const contents = `${JSON.stringify(data, null, 2)}\n`; @@ -45,6 +56,21 @@ const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // would break the surrounding structure. const oneLine = (value) => String(value ?? '').replace(/\s+/g, ' ').trim(); +// Match sanitize_text_field() for generated plain-text metadata, including its +// removal of percent-encoded octets. Encoding source files is handled separately. +const sanitizeTextField = (value) => { + let text = String(value) + .replace(/<(script|style)\b[^>]*>[\s\S]*?<\/\1>/gi, '') + .replace(/<[^>]*?((?=<)|>|$)/g, (part) => part.endsWith('>') ? part : part.replace(/&/g, '&').replace(/|<(?:[^>\"']|\"[^\"]*\"|'[^']*')*>/g, '') + .replace(/[\r\n\t ]+/g, ' ') + .trim(); + while (/%[a-f0-9]{2}/i.test(text)) { + text = text.replace(/%[a-f0-9]{2}/gi, '').replace(/ +/g, ' ').trim(); + } + return text; +}; + const readThemeHeader = (contents, field) => { const match = contents.match( new RegExp(`^\\s*\\*\\s*${escapeRegExp(field)}:\\s*(.+?)\\s*$`, 'mi'), @@ -94,6 +120,12 @@ const getProjectConfig = () => { throw new Error('project.emulsify.json project.machineName must be a non-empty string.'); } + const canonical = machineName.replace(/[A-Z]/g, (letter) => letter.toLowerCase()).replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''); + if (machineName !== canonical || canonical === '') { + const advice = canonical ? ` Re-run Emulsify CLI with --machineName ${canonical}.` : ''; + throw new Error(`project.machineName must be canonical: lowercase letters, digits and single hyphens, with no leading or trailing hyphen.${advice} The CLI chooses the project directory before this hook runs.`); + } + return config; }; @@ -123,7 +155,8 @@ const getDescription = (config) => { const declared = config.project.description; if (typeof declared === 'string' && declared.trim() !== '') { - return oneLine(declared); + const sanitized = sanitizeTextField(declared); + if (sanitized !== '') return oneLine(sanitized); } const starter = readThemeHeader( @@ -248,7 +281,7 @@ const walkJsonFiles = (directory) => { return []; } - return fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + return fs.readdirSync(directory, { withFileTypes: true }).sort((left, right) => left.name.localeCompare(right.name)).flatMap((entry) => { const filePath = path.join(directory, entry.name); if (entry.isDirectory()) { @@ -259,10 +292,8 @@ const walkJsonFiles = (directory) => { }); }; -const updatePatternNamespaces = ({ machineName }) => { - for (const filePath of walkJsonFiles(path.join(ROOT, 'patterns'))) { - const data = readJson(filePath); - +const updatePatternNamespaces = ({ machineName }, patterns) => { + for (const { filePath, data } of patterns) { if (typeof data.name === 'string' && data.name.startsWith(`${STARTER_SLUG}/`)) { data.name = `${machineName}/${data.name.slice(STARTER_SLUG.length + 1)}`; } @@ -275,9 +306,12 @@ const updatePatternNamespaces = ({ machineName }) => { const main = () => { const config = getProjectConfig(); + const name = sanitizeTextField(config.project.name) || 'New Theme'; + // Validate every pattern before rewriting any generated files. + const patterns = walkJsonFiles(path.join(ROOT, 'patterns')).map((filePath) => ({ filePath, data: readJson(filePath) })); const project = { - name: config.project.name, - sourceLabel: sanitizeLabelForSource(config.project.name), + name, + sourceLabel: sanitizeLabelForSource(name), machineName: config.project.machineName, generatedFromVersion: getGeneratedFromVersion(), description: getDescription(config), @@ -293,7 +327,7 @@ const main = () => { updateLockfile('npm-shrinkwrap.json', project); updateProjectConfig(config, project); updateFunctionsPhp(project); - updatePatternNamespaces(project); + updatePatternNamespaces(project, patterns); // Generation-only tooling should not linger in a project repository. Removing // it last keeps the hook available if any step above throws, and keeps this From eb2f448f6680246978d9e51b3ddd7c9fcf8040de Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Thu, 29 Oct 2026 11:15:12 -0500 Subject: [PATCH 091/100] fix(build): package only committed files without symlinks --- .github/scripts/build-dist.test.cjs | 154 ++++++++++++++++++++++++++++ .github/scripts/pr-validation.cjs | 1 + .github/scripts/release-check.cjs | 2 +- docs/release-notes-next.md | 3 + docs/release-process.md | 12 ++- scripts/build-dist.sh | 74 ++++++------- 6 files changed, 207 insertions(+), 39 deletions(-) create mode 100644 .github/scripts/build-dist.test.cjs diff --git a/.github/scripts/build-dist.test.cjs b/.github/scripts/build-dist.test.cjs new file mode 100644 index 0000000..d8e7df7 --- /dev/null +++ b/.github/scripts/build-dist.test.cjs @@ -0,0 +1,154 @@ +const assert = require('node:assert/strict'); +const { execFileSync, spawnSync } = require('node:child_process'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { test } = require('node:test'); + +const builder = fs.readFileSync(path.resolve(__dirname, '../../scripts/build-dist.sh'), 'utf8'); + +function fixture(t) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'emulsify-build-dist-')); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const repo = path.join(root, 'checkout'); + const bin = path.join(root, 'bin'); + const composerLog = path.join(root, 'composer.json'); + fs.mkdirSync(repo); + fs.mkdirSync(bin); + const write = (relative, contents) => { + const file = path.join(repo, relative); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, contents); + }; + const git = (...args) => execFileSync('git', [ + '-c', 'commit.gpgsign=false', '-c', 'tag.gpgSign=false', + '-c', 'user.name=Distribution Test', '-c', 'user.email=dist@example.invalid', + ...args, + ], { cwd: repo, encoding: 'utf8', stdio: 'pipe' }).trim(); + + for (const relative of [ + '404.php', 'AGENTS.md', 'LICENSE', 'README.md', 'UPGRADE.md', 'archive.php', + 'author.php', 'functions.php', 'index.php', 'page.php', 'screenshot.png', + 'search.php', 'single.php', 'style.css', 'theme.json', + 'docs/wp-cli-child-theme-generation.md', 'includes/sentinel.php', + 'src/sentinel.txt', 'templates/sentinel.twig', 'whisk/README.md', + 'whisk/AGENTS.md', 'whisk/.agents/skills/emulsify-wordpress-integration/SKILL.md', + 'whisk/docs/wordpress-integration.md', 'whisk/package.json', + 'whisk/project.emulsify.json', 'whisk/style.css', 'whisk/.cli/init.js', + ]) { + write(relative, `committed ${relative}\n`); + } + write('composer.json', '{"name":"fixture/production"}\n'); + write('composer.lock', '{"committed":true}\n'); + // The release builder must read these Git objects even though git archive + // would omit them. Root Composer metadata is needed only during installation. + write('.gitattributes', '/composer.lock export-ignore\n/whisk/package.json export-ignore\n'); + write('.gitignore', 'dist-artifact/\n'); + write('scripts/build-dist.sh', builder); + git('init', '-b', 'main'); + git('add', '.'); + git('commit', '-m', 'chore: create distribution fixture'); + + const composer = path.join(bin, 'composer'); + fs.writeFileSync(composer, `#!/usr/bin/env node +const fs = require('node:fs'); +const path = require('node:path'); +const root = process.argv.find((argument) => argument.startsWith('--working-dir=')).slice('--working-dir='.length); +fs.writeFileSync(process.env.EMULSIFY_BUILD_TEST_COMPOSER_LOG, JSON.stringify({ + lock: fs.readFileSync(path.join(root, 'composer.lock'), 'utf8'), + manifest: fs.readFileSync(path.join(root, 'composer.json'), 'utf8'), +})); +fs.mkdirSync(path.join(root, 'vendor'), { recursive: true }); +fs.writeFileSync(path.join(root, 'vendor/autoload.php'), ' spawnSync('bash', ['scripts/build-dist.sh'], { + cwd: repo, + encoding: 'utf8', + env: { + ...process.env, + PATH: `${bin}${path.delimiter}${process.env.PATH}`, + EMULSIFY_BUILD_TEST_COMPOSER_LOG: composerLog, + }, + }); + const contents = (relative) => execFileSync('unzip', ['-p', archive, `emulsify/${relative}`], { encoding: 'utf8' }); + const list = () => execFileSync('unzip', ['-Z1', archive], { encoding: 'utf8' }).trim().split('\n'); + return { repo, write, git, run, archive, contents, list, composerLog }; +} + +function assertBuilt(result) { + assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); +} + +test('distribution uses committed bytes and excludes staged or untracked additions', (t) => { + const build = fixture(t); + build.write('includes/sentinel.php', 'uncommitted runtime edit\n'); + build.write('includes/staged-secret.php', 'staged secret\n'); + build.write('whisk/untracked-secret.txt', 'untracked secret\n'); + build.git('add', 'includes/sentinel.php', 'includes/staged-secret.php'); + assertBuilt(build.run()); + assert.equal(build.contents('includes/sentinel.php'), 'committed includes/sentinel.php\n'); + assert.ok(!build.list().includes('emulsify/includes/staged-secret.php')); + assert.ok(!build.list().includes('emulsify/whisk/untracked-secret.txt')); +}); + +test('distribution installs committed Composer metadata without export-ignore losses', (t) => { + const build = fixture(t); + build.write('composer.lock', '{"uncommitted":true}\n'); + build.write('composer.json', '{"name":"uncommitted/production"}\n'); + assertBuilt(build.run()); + assert.deepEqual(JSON.parse(fs.readFileSync(build.composerLog, 'utf8')), { + lock: '{"committed":true}\n', manifest: '{"name":"fixture/production"}\n', + }); + assert.equal(build.contents('whisk/package.json'), 'committed whisk/package.json\n'); + const entries = build.list(); + assert.ok(entries.includes('emulsify/whisk/.agents/skills/emulsify-wordpress-integration/SKILL.md')); + assert.ok(entries.includes('emulsify/vendor/autoload.php')); + assert.ok(!entries.some((entry) => /(?:^|\/)composer\.(?:json|lock)$|\/\.cli\//.test(entry))); +}); + +test('distribution refuses committed symlinks instead of copying external contents', (t) => { + const build = fixture(t); + const external = path.join(path.dirname(build.repo), 'private.txt'); + fs.writeFileSync(external, 'must never ship\n'); + fs.symlinkSync(external, path.join(build.repo, 'includes/external.txt')); + build.git('add', 'includes/external.txt'); + build.git('commit', '-m', 'test: add symlink fixture'); + const result = build.run(); + assert.notEqual(result.status, 0, 'A tracked symlink must abort packaging before its target is copied.'); + assert.match(result.stderr, /Refusing.*(?:tracked|committed).*symlink/i); + assert.match(result.stderr, /includes\/external\.txt/); + assert.ok(!fs.existsSync(build.archive)); + assert.ok(!fs.existsSync(build.composerLog), 'Symlink rejection must precede Composer installation.'); +}); + +test('distribution stays on its starting commit if HEAD moves during staging', (t) => { + const build = fixture(t); + const actualGit = execFileSync('sh', ['-c', 'command -v git'], { encoding: 'utf8' }).trim(); + const bin = path.join(path.dirname(build.repo), 'bin'); + const marker = path.join(bin, 'moved-head'); + const gitShim = path.join(bin, 'git'); + fs.writeFileSync(gitShim, `#!/usr/bin/env node +const { execFileSync, spawnSync } = require('node:child_process'); +const fs = require('node:fs'); +const args = process.argv.slice(2); +const git = ${JSON.stringify(actualGit)}; +const root = ${JSON.stringify(build.repo)}; +const marker = ${JSON.stringify(marker)}; +if (args.includes('cat-file') && !fs.existsSync(marker)) { + fs.writeFileSync(marker, 'moved'); + fs.writeFileSync(root + '/includes/sentinel.php', 'new commit during staging\\n'); + execFileSync(git, ['-C', root, 'add', 'includes/sentinel.php']); + execFileSync(git, ['-C', root, '-c', 'commit.gpgsign=false', '-c', 'user.name=Distribution Test', '-c', 'user.email=dist@example.invalid', 'commit', '-m', 'test: simulate concurrent commit']); +} +const result = spawnSync(git, args, { stdio: 'inherit' }); +process.exit(result.status ?? 1); +`); + fs.chmodSync(gitShim, 0o755); + const original = build.git('rev-parse', 'HEAD'); + assertBuilt(build.run()); + assert.ok(fs.existsSync(marker), 'The fixture must move HEAD before the remaining payload is staged.'); + assert.notEqual(build.git('rev-parse', 'HEAD'), original); + assert.equal(build.contents('includes/sentinel.php'), 'committed includes/sentinel.php\n'); +}); diff --git a/.github/scripts/pr-validation.cjs b/.github/scripts/pr-validation.cjs index 5e61b84..ae24809 100644 --- a/.github/scripts/pr-validation.cjs +++ b/.github/scripts/pr-validation.cjs @@ -25,6 +25,7 @@ function run(label, command, args) { run('Validate Composer metadata', 'composer', ['validate', '--no-check-publish', '--strict']); run('Validate documented npm commands', 'npm', ['run', 'docs:check-commands']); run('Protect blocking readiness coverage', 'node', ['--test', '.github/scripts/readiness-matrix-contract.test.cjs']); +run('Protect committed release inputs and reject symlinks', 'node', ['--test', '.github/scripts/build-dist.test.cjs']); run('Install Composer dependencies for Twig smoke coverage', 'composer', [ 'install', '--no-interaction', diff --git a/.github/scripts/release-check.cjs b/.github/scripts/release-check.cjs index 20b3bca..d42fdb8 100644 --- a/.github/scripts/release-check.cjs +++ b/.github/scripts/release-check.cjs @@ -1344,7 +1344,7 @@ function runStaticChecks() { ensure(distBuilder.includes('"docs"') && distBuilder.includes('"includes"') && distBuilder.includes('"templates"') && distBuilder.includes('"src"'), 'Distribution build should include user documentation and parent runtime directories.'); ensure(distBuilder.includes('"UPGRADE.md"') && distBuilder.includes('"docs/wp-cli-child-theme-generation.md"'), 'Distribution build should retain the documentation linked from its README.'); ensure(distBuilder.includes('"functions.php"') && distBuilder.includes('"style.css"') && distBuilder.includes('"theme.json"'), 'Distribution build should include required WordPress theme files.'); - ensure(distBuilder.includes('copy_tracked_directory "whisk"'), 'Distribution build should include the tracked Whisk generator payload.'); + ensure(distBuilder.includes('copy_committed_directory "whisk"'), 'Distribution build should include the committed Whisk generator payload.'); ensure(distBuilder.includes('"whisk/project.emulsify.json"') && distBuilder.includes('"whisk/style.css"'), 'Distribution build should verify required Whisk generator files.'); ensure(distBuilder.includes('"whisk/.cache"') && distBuilder.includes('"whisk/.cli"') && distBuilder.includes('"whisk/node_modules"'), 'Distribution build should reject Whisk caches, generation tooling, and dependencies.'); ensure(distBuilder.includes('find emulsify -type f -print') && distBuilder.includes('zip -X'), 'Distribution build should archive the staged emulsify/ tree reproducibly.'); diff --git a/docs/release-notes-next.md b/docs/release-notes-next.md index 534896c..63f84ef 100644 --- a/docs/release-notes-next.md +++ b/docs/release-notes-next.md @@ -77,6 +77,9 @@ lineage. See the [release process](release-process.md). ## Fixed +- Installable ZIPs contain only committed inputs from one Git revision and + refuse symlinks instead of packaging their targets. + - Both child-theme generators preserve JSON object/list types, rewrite nested patterns, sanitize metadata consistently, and stop on invalid JSON without installing a partially processed starter. Standalone generation rejects diff --git a/docs/release-process.md b/docs/release-process.md index ad9dcbf..24043ca 100644 --- a/docs/release-process.md +++ b/docs/release-process.md @@ -217,7 +217,17 @@ before any tag is created, including during dry runs. ## Installable release artifact -Run `npm run build:dist` to create `dist-artifact/emulsify.zip`. The build stages an explicit parent-theme runtime file list under a top-level `emulsify/` directory, copies only tracked Whisk generator files, and installs the versions pinned in `composer.lock` with `--no-dev --optimize-autoloader` directly into that staged tree. The ZIP therefore bundles `vendor/` and can be installed without running Composer after download. +Run `npm run build:dist` to create `dist-artifact/emulsify.zip`. Commit source +changes before building: the build reads one immutable `HEAD` commit, including +Composer metadata and Whisk payload, and excludes staged or working-tree edits. +It refuses committed symlinks before installing dependencies. Git objects are +read explicitly so `export-ignore` cannot omit the lockfile needed for the build. + +The build stages an explicit parent-theme runtime file list under a top-level +`emulsify/` directory and installs the committed versions pinned in +`composer.lock` with `--no-dev --optimize-autoloader` directly into that tree. +The ZIP bundles `vendor/` and can be installed without running Composer after +download. The archive includes the runtime PHP entry points, `includes/`, `templates/`, `src/`, `style.css`, `theme.json`, the screenshot, license, README, linked user documentation, production Composer dependencies, and the tracked `whisk/` child starter required by `wp emulsify`. It excludes repository metadata, `.github/`, root npm and Composer metadata, `node_modules/`, development configuration, smoke tests, and Whisk's standalone `.cli` hook, caches, dependencies, and build output. diff --git a/scripts/build-dist.sh b/scripts/build-dist.sh index 802187e..d2aede8 100755 --- a/scripts/build-dist.sh +++ b/scripts/build-dist.sh @@ -13,11 +13,33 @@ for command_name in composer git node zip; do fi done -copy_tracked_directory() { +# Resolve one immutable commit before reading any payload. Working-tree and index +# edits never become release inputs, even if HEAD moves while the build runs. +build_commit="$(git -C "${repo_root}" rev-parse --verify 'HEAD^{commit}')" + +while IFS= read -r -d '' tree_entry; do + case "${tree_entry}" in + '120000 '*) + echo "Refusing to package committed symlink: ${tree_entry#*$'\t'}" >&2 + exit 1 + ;; + esac +done < <(git -C "${repo_root}" ls-tree -r -z "${build_commit}") + +copy_committed_file() { + local relative_path="$1" + local target_path="${staging_root}/${relative_path}" + + mkdir -p "$(dirname "${target_path}")" + if ! git -C "${repo_root}" cat-file blob "${build_commit}:${relative_path}" > "${target_path}"; then + echo "Required committed file not found: ${relative_path}" >&2 + exit 1 + fi +} + +copy_committed_directory() { local relative_directory="$1" local relative_path - local source_path - local target_path local copied_files=0 while IFS= read -r -d '' relative_path; do @@ -27,15 +49,12 @@ copy_tracked_directory() { ;; esac - source_path="${repo_root}/${relative_path}" - target_path="${staging_root}/${relative_path}" - mkdir -p "$(dirname "${target_path}")" - cp "${source_path}" "${target_path}" + copy_committed_file "${relative_path}" copied_files=$((copied_files + 1)) - done < <(git -C "${repo_root}" ls-files -z -- "${relative_directory}") + done < <(git -C "${repo_root}" ls-tree -r -z --name-only "${build_commit}" -- "${relative_directory}") if (( copied_files == 0 )); then - echo "No tracked files found for required directory: ${relative_directory}" >&2 + echo "No committed files found for required directory: ${relative_directory}" >&2 exit 1 fi } @@ -78,36 +97,21 @@ runtime_directories=( ) for relative_path in "${runtime_files[@]}"; do - source_path="${repo_root}/${relative_path}" - if [[ ! -f "${source_path}" ]]; then - echo "Required runtime path not found: ${relative_path}" >&2 - exit 1 - fi - - cp "${source_path}" "${staging_root}/" + copy_committed_file "${relative_path}" done for relative_directory in "${runtime_directories[@]}"; do - if [[ ! -d "${repo_root}/${relative_directory}" ]]; then - echo "Required runtime directory not found: ${relative_directory}" >&2 - exit 1 - fi - - copy_tracked_directory "${relative_directory}" + copy_committed_directory "${relative_directory}" done -# Whisk is product payload for `wp emulsify`, but only its tracked generator -# source belongs in the release. This prevents ignored local dependencies, -# caches, and build output from leaking into the archive. -copy_tracked_directory "whisk" +# Whisk is product payload for `wp emulsify`, but only its committed generator +# source belongs in the release. Development output stays outside the archive. +copy_committed_directory "whisk" +# Read Git objects explicitly rather than git archive: export-ignore omits the +# lockfile from Git distributions, but Composer needs it for reproducible builds. for composer_file in composer.json composer.lock; do - if [[ ! -f "${repo_root}/${composer_file}" ]]; then - echo "Required Composer metadata not found: ${composer_file}" >&2 - exit 1 - fi - - cp "${repo_root}/${composer_file}" "${staging_root}/" + copy_committed_file "${composer_file}" done echo "Installing production Composer dependencies..." @@ -169,11 +173,7 @@ for forbidden_path in \ done if [[ -z "${SOURCE_DATE_EPOCH:-}" ]]; then - if git -C "${repo_root}" rev-parse --is-inside-work-tree >/dev/null 2>&1; then - SOURCE_DATE_EPOCH="$(git -C "${repo_root}" log -1 --format=%ct)" - else - SOURCE_DATE_EPOCH=315532800 - fi + SOURCE_DATE_EPOCH="$(git -C "${repo_root}" show --no-show-signature -s --format=%ct "${build_commit}")" fi if [[ ! "${SOURCE_DATE_EPOCH}" =~ ^[0-9]+$ ]] || (( SOURCE_DATE_EPOCH < 315532800 )); then From ab7c083fa0e8919ae6d546198d7c791f5c0c1e49 Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Thu, 29 Oct 2026 11:17:40 -0500 Subject: [PATCH 092/100] fix(assets): keep scoped subfolder assets off unrelated pages --- .github/scripts/asset-manifest-smoke.php | 2 + .github/scripts/block-scoped-assets-smoke.php | 47 +++++++++++++- docs/acf-twig-blocks.md | 8 +++ docs/release-notes-next.md | 3 + includes/Blocks/ComponentLocator.php | 17 ++++++ includes/Blocks/Registry.php | 21 ++++--- includes/Bootstrap.php | 5 +- includes/Runtime/Assets.php | 61 ++++++++++++------- 8 files changed, 130 insertions(+), 34 deletions(-) diff --git a/.github/scripts/asset-manifest-smoke.php b/.github/scripts/asset-manifest-smoke.php index fa79faf..c2e77f0 100644 --- a/.github/scripts/asset-manifest-smoke.php +++ b/.github/scripts/asset-manifest-smoke.php @@ -216,6 +216,8 @@ function emulsify_asset_manifest_smoke_remove( string $path ): void { require_once $repo_root . '/includes/Support/AssetRecord.php'; require_once $repo_root . '/includes/Support/AssetEnqueuer.php'; require_once $repo_root . '/includes/Support/AssetManifest.php'; + require_once $repo_root . '/includes/Support/Diagnostics.php'; + require_once $repo_root . '/includes/Blocks/ComponentLocator.php'; require_once $repo_root . '/includes/Runtime/Assets.php'; require_once $repo_root . '/includes/Editor/Enhancements.php'; diff --git a/.github/scripts/block-scoped-assets-smoke.php b/.github/scripts/block-scoped-assets-smoke.php index f79c12a..dcf92de 100644 --- a/.github/scripts/block-scoped-assets-smoke.php +++ b/.github/scripts/block-scoped-assets-smoke.php @@ -456,15 +456,58 @@ static function ( array $records, array $component ): array { 'title' => 'Scanner Card', 'assets' => array( 'frontend' => array( - 'css' => array( array( 'path' => 'card.css' ) ), + 'css' => array( array( 'path' => 'card.css' ), array( 'path' => 'styles/nested.css' ) ), + 'js' => array( array( 'path' => 'scripts/nested.js' ) ), + ), + 'editor' => array( + 'css' => array( array( 'path' => 'editor/editor.css' ) ), + 'js' => array( array( 'path' => 'editor/editor.js' ) ), ), ), ) ); emulsify_block_assets_smoke_write( $GLOBALS['emulsify_block_assets_smoke_child'] . '/dist/components/card/card.css', '.card{}' ); + emulsify_block_assets_smoke_write( $GLOBALS['emulsify_block_assets_smoke_child'] . '/dist/components/card/styles/nested.css', '.nested{}' ); + emulsify_block_assets_smoke_write( $GLOBALS['emulsify_block_assets_smoke_child'] . '/dist/components/card/scripts/nested.js', 'window.nested = true;' ); + emulsify_block_assets_smoke_write( $GLOBALS['emulsify_block_assets_smoke_child'] . '/dist/components/card/editor/editor.css', '.editor{}' ); + emulsify_block_assets_smoke_write( $GLOBALS['emulsify_block_assets_smoke_child'] . '/dist/components/card/editor/editor.js', 'window.editor = true;' ); emulsify_block_assets_smoke_write( $GLOBALS['emulsify_block_assets_smoke_child'] . '/dist/components/plain/plain.css', '.plain{}' ); + $filtered_components = $work_root . '/filtered-components'; + emulsify_block_assets_smoke_write_json( + $filtered_components . '/card/card.component.json', + array( 'assets' => array( 'css' => array( 'styles/filtered.css' ) ) ) + ); + emulsify_block_assets_smoke_write( $filtered_components . '/card/styles/filtered.css', '.filtered{}' ); + add_filter( + 'emulsify_theme_asset_directories', + static function ( array $roots, string $directory ) use ( $filtered_components ): array { + if ( 'dist/components' === $directory ) { + $roots[] = array( 'path' => $filtered_components, 'uri' => 'https://example.test/filtered-components' ); + } - ( new Emulsify\Theme\Runtime\Assets() )->styles(); + return $roots; + }, + 10, + 2 + ); + + $scanner_assets = new Emulsify\Theme\Runtime\Assets(); + $scanner_assets->styles(); + $scanner_assets->frontend_scripts(); + + emulsify_block_assets_smoke_assert( + ! isset( $GLOBALS['emulsify_block_assets_smoke_styles']['emulsify-component-card-styles-nested'] ) + && ! isset( $GLOBALS['emulsify_block_assets_smoke_styles']['emulsify-component-card-editor-editor'] ) + && ! isset( $GLOBALS['emulsify_block_assets_smoke_scripts']['emulsify-component-card-scripts-nested'] ) + && ! isset( $GLOBALS['emulsify_block_assets_smoke_script_modules']['emulsify-component-card-scripts-nested'] ) + && ! isset( $GLOBALS['emulsify_block_assets_smoke_scripts']['emulsify-component-card-editor-editor'] ) + && ! isset( $GLOBALS['emulsify_block_assets_smoke_script_modules']['emulsify-component-card-editor-editor'] ), + 'Global component scanning must skip frontend and editor-only metadata assets in component subdirectories without a manifest.' + ); + emulsify_block_assets_smoke_assert( + ! isset( $GLOBALS['emulsify_block_assets_smoke_styles']['emulsify-component-card-styles-filtered'] ), + 'Scoped metadata in filtered asset-only roots must stay scoped without requiring a component-roots filter.' + ); emulsify_block_assets_smoke_assert( ! isset( $GLOBALS['emulsify_block_assets_smoke_styles']['emulsify-component-card-card'] ), diff --git a/docs/acf-twig-blocks.md b/docs/acf-twig-blocks.md index db4ef67..e2863ed 100644 --- a/docs/acf-twig-blocks.md +++ b/docs/acf-twig-blocks.md @@ -106,3 +106,11 @@ Scoped asset records can be adjusted before registration with `emulsify_theme_ac The child theme is scanned before the parent theme. Duplicate component paths, duplicate component slugs, and duplicate final ACF block `name` values are skipped instead of being registered twice. When `WP_DEBUG` is enabled, skipped duplicates are logged and shown as admin-only notices to users who can edit themes. Normal frontend visitors do not see duplicate diagnostics. + +### Scoped assets in subfolders + +Asset paths declared by `*.component.json` remain component-scoped even when +CSS or JavaScript lives in nested folders. The parent reuses its component +metadata index and asset scan, including custom asset-only roots. Editor-only +files are excluded from global frontend enqueueing; components without a Twig +template retain that exclusion too. diff --git a/docs/release-notes-next.md b/docs/release-notes-next.md index 63f84ef..c0f7007 100644 --- a/docs/release-notes-next.md +++ b/docs/release-notes-next.md @@ -77,6 +77,9 @@ lineage. See the [release process](release-process.md). ## Fixed +- Component-scoped CSS and JavaScript in subfolders, including editor-only + files, no longer load globally when no asset manifest is present. + - Installable ZIPs contain only committed inputs from one Git revision and refuse symlinks instead of packaging their targets. diff --git a/includes/Blocks/ComponentLocator.php b/includes/Blocks/ComponentLocator.php index 2c37b17..814067c 100644 --- a/includes/Blocks/ComponentLocator.php +++ b/includes/Blocks/ComponentLocator.php @@ -98,6 +98,23 @@ final class ComponentLocator { */ private $skipped_duplicates = array(); + /** + * Gets component metadata files, including components without a Twig template. + * + * @return array Absolute metadata paths in child-first discovery order. + */ + public function component_metadata_files(): array { + $paths = array(); + + foreach ( $this->component_files() as $file ) { + if ( preg_match( '/\.component\.json$/', basename( $file['path'] ) ) ) { + $paths[] = $file['path']; + } + } + + return $paths; + } + /** * Clears the optional persistent discovery cache for the current theme state. * diff --git a/includes/Blocks/Registry.php b/includes/Blocks/Registry.php index c61cea0..3f8646d 100644 --- a/includes/Blocks/Registry.php +++ b/includes/Blocks/Registry.php @@ -21,13 +21,22 @@ final class Registry { */ private $manifest; + /** + * Shared component metadata index. + * + * @var ComponentLocator + */ + private $components; + /** * Constructor. * - * @param AssetManifest|null $manifest Asset manifest reader. + * @param AssetManifest|null $manifest Asset manifest reader. + * @param ComponentLocator|null $components Shared component metadata index. */ - public function __construct( ?AssetManifest $manifest = null ) { - $this->manifest = $manifest ?? new AssetManifest(); + public function __construct( ?AssetManifest $manifest = null, ?ComponentLocator $components = null ) { + $this->manifest = $manifest ?? new AssetManifest(); + $this->components = $components ?? new ComponentLocator(); } /** @@ -43,11 +52,9 @@ static function (): void { } ); - $components = new ComponentLocator(); - // Share one locator so ACF/Twig and native block registration use the same // request-local child-first component index and duplicate diagnostics. - ( new AcfBlocks( $components, $this->manifest ) )->register(); - ( new NativeBlocks( $components ) )->register(); + ( new AcfBlocks( $this->components, $this->manifest ) )->register(); + ( new NativeBlocks( $this->components ) )->register(); } } diff --git a/includes/Bootstrap.php b/includes/Bootstrap.php index bdfc9ec..94b2c25 100644 --- a/includes/Bootstrap.php +++ b/includes/Bootstrap.php @@ -64,18 +64,19 @@ private function register(): void { $this->load_classes(); $asset_manifest = new AssetManifest(); + $components = new Blocks\ComponentLocator(); // These services use WordPress APIs directly and must stay available even // when Timber is missing. The MissingTimber service owns frontend failure // handling later in this method. ( new Setup() )->register(); - ( new Assets( $asset_manifest ) )->register(); + ( new Assets( $asset_manifest, $components ) )->register(); ( new LocalJson() )->register(); ( new CoreBlockTwigRenderer() )->register(); ( new Enhancements( $asset_manifest ) )->register(); ( new Policy() )->register(); ( new Patterns() )->register(); - ( new Blocks\Registry( $asset_manifest ) )->register(); + ( new Blocks\Registry( $asset_manifest, $components ) )->register(); ( new GenerateChildThemeCommand() )->register(); ( new Embeds() )->register(); diff --git a/includes/Runtime/Assets.php b/includes/Runtime/Assets.php index f5280ce..8e9dcdf 100644 --- a/includes/Runtime/Assets.php +++ b/includes/Runtime/Assets.php @@ -7,6 +7,7 @@ namespace Emulsify\Theme\Runtime; +use Emulsify\Theme\Blocks\ComponentLocator; use Emulsify\Theme\Support\AssetEnqueuer; use Emulsify\Theme\Support\AssetManifest; use Emulsify\Theme\Support\AssetRecord; @@ -24,6 +25,13 @@ final class Assets { */ private $manifest; + /** + * Shared component metadata index. + * + * @var ComponentLocator + */ + private $components; + /** * Memoized raw asset file records by theme-relative directory. * @@ -34,9 +42,9 @@ final class Assets { /** * Memoized component metadata asset paths. * - * @var array + * @var array|null */ - private $component_asset_paths = array(); + private $component_asset_paths; /** * Memoized manifest-scoped component asset paths. @@ -48,10 +56,12 @@ final class Assets { /** * Constructor. * - * @param AssetManifest|null $manifest Asset manifest reader. + * @param AssetManifest|null $manifest Asset manifest reader. + * @param ComponentLocator|null $components Shared component metadata index. */ - public function __construct( ?AssetManifest $manifest = null ) { - $this->manifest = $manifest ?? new AssetManifest(); + public function __construct( ?AssetManifest $manifest = null, ?ComponentLocator $components = null ) { + $this->manifest = $manifest ?? new AssetManifest(); + $this->components = $components ?? new ComponentLocator(); } /** @@ -234,7 +244,9 @@ private function raw_asset_file_records( string $directory ): array { $key = trim( $directory, '/\\' ); if ( ! array_key_exists( $key, $this->asset_file_records ) ) { - $this->asset_file_records[ $key ] = FileDiscovery::file_records( $this->asset_roots( $directory ), array( 'css', 'js' ) ); + // Metadata in asset-only filtered roots belongs to this same scan, too. + $extensions = 'dist/components' === $key ? array( 'css', 'js', 'json' ) : array( 'css', 'js' ); + $this->asset_file_records[ $key ] = FileDiscovery::file_records( $this->asset_roots( $directory ), $extensions ); } return $this->asset_file_records[ $key ]; @@ -348,11 +360,9 @@ private function is_scoped_component_asset( string $directory, array $file ): bo return true; } - $component_dir = dirname( (string) $file['path'] ); - $relative = FileDiscovery::relative_path( $component_dir, (string) $file['path'] ); - $asset_paths = $this->component_asset_paths( $component_dir ); + $asset_paths = $this->component_asset_paths(); - return isset( $asset_paths[ $relative ] ); + return isset( $asset_paths[ $file['path'] ] ); } /** @@ -371,25 +381,30 @@ private function manifest_component_asset_paths(): array { } /** - * Gets declared component metadata asset paths for a component directory. + * Gets declared metadata asset paths from the shared component file index. * - * @param string $component_dir Absolute component directory path. - * @return array Declared asset paths keyed by relative path. + * Metadata-only components are included even when they cannot register a + * Twig block. Their declared editor files must never become global assets. + * + * @return array Declared asset paths keyed by absolute path. */ - private function component_asset_paths( string $component_dir ): array { - if ( isset( $this->component_asset_paths[ $component_dir ] ) ) { - return $this->component_asset_paths[ $component_dir ]; + private function component_asset_paths(): array { + if ( is_array( $this->component_asset_paths ) ) { + return $this->component_asset_paths; } $paths = array(); - $metadata_files = glob( rtrim( $component_dir, '/\\' ) . '/*.component.json' ); + $metadata_files = $this->components->component_metadata_files(); - if ( ! is_array( $metadata_files ) ) { - $this->component_asset_paths[ $component_dir ] = $paths; - return $paths; + // Preserve metadata scoping for roots added only to the asset directory + // filter, without walking those roots a second time. + foreach ( $this->raw_asset_file_records( 'dist/components' ) as $file ) { + if ( preg_match( '/\.component\.json$/', basename( $file['path'] ) ) ) { + $metadata_files[] = $file['path']; + } } - foreach ( $metadata_files as $metadata_file ) { + foreach ( array_unique( $metadata_files ) as $metadata_file ) { $contents = is_readable( $metadata_file ) ? file_get_contents( $metadata_file ) : false; if ( ! is_string( $contents ) ) { @@ -403,11 +418,11 @@ private function component_asset_paths( string $component_dir ): array { } foreach ( $this->metadata_asset_paths( $metadata['assets'] ) as $path ) { - $paths[ $path ] = true; + $paths[ dirname( $metadata_file ) . '/' . $path ] = true; } } - $this->component_asset_paths[ $component_dir ] = $paths; + $this->component_asset_paths = $paths; return $paths; } From ac8e8b27875f51fd6da67c1c858894cc86226a7b Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Thu, 29 Oct 2026 11:17:55 -0500 Subject: [PATCH 093/100] fix(cli): handle symlinked starter directories --- .github/scripts/child-theme-generator-smoke.php | 12 ++++++++++++ docs/release-notes-next.md | 3 +++ includes/Cli/GenerateChildThemeCommand.php | 10 +++++----- 3 files changed, 20 insertions(+), 5 deletions(-) diff --git a/.github/scripts/child-theme-generator-smoke.php b/.github/scripts/child-theme-generator-smoke.php index 529a614..e623823 100644 --- a/.github/scripts/child-theme-generator-smoke.php +++ b/.github/scripts/child-theme-generator-smoke.php @@ -574,6 +574,18 @@ static function ( array $message ): bool { emulsify_cli_smoke_assert( 'active-child' === $GLOBALS['emulsify_activated_theme'], '--activate should call switch_theme() with the child slug.' ); + $external_directory = $work_root . '/external-starter-directory'; + mkdir( $external_directory ); + file_put_contents( $external_directory . '/secret.txt', 'outside starter' ); + symlink( $external_directory, $parent_root . '/whisk/linked-directory' ); + symlink( $external_directory . '/secret.txt', $parent_root . '/whisk/linked-file.txt' ); + WP_CLI::$messages = array(); + $cli( array( 'Symlink Theme' ), array( 'machine-name' => 'symlink-child' ) ); + emulsify_cli_smoke_assert( ! file_exists( $theme_root . '/symlink-child/linked-directory' ), 'A symlinked starter directory must be skipped rather than copied as an empty directory.' ); + emulsify_cli_smoke_assert( ! file_exists( $theme_root . '/symlink-child/linked-file.txt' ), 'A symlinked starter file must not copy an external file.' ); + $symlink_warnings = array_filter( WP_CLI::$messages, static fn( array $message ): bool => 'warning' === $message['type'] && str_contains( $message['message'], 'Skipping symlink' ) ); + emulsify_cli_smoke_assert( 2 === count( $symlink_warnings ), 'Each skipped starter symlink must report a warning.' ); + echo "Child theme generator smoke checks passed.\n"; } catch ( Throwable $throwable ) { fwrite( STDERR, $throwable->getMessage() . "\n" ); diff --git a/docs/release-notes-next.md b/docs/release-notes-next.md index c0f7007..3971d0f 100644 --- a/docs/release-notes-next.md +++ b/docs/release-notes-next.md @@ -77,6 +77,9 @@ lineage. See the [release process](release-process.md). ## Fixed +- WP-CLI skips symlinked starter directories with a warning, instead of + silently creating empty destination directories. + - Component-scoped CSS and JavaScript in subfolders, including editor-only files, no longer load globally when no asset manifest is present. diff --git a/includes/Cli/GenerateChildThemeCommand.php b/includes/Cli/GenerateChildThemeCommand.php index 7ef4cda..f18f381 100644 --- a/includes/Cli/GenerateChildThemeCommand.php +++ b/includes/Cli/GenerateChildThemeCommand.php @@ -253,6 +253,11 @@ private function copy_theme( string $source, string $destination ): bool { $target = $this->join_path( $destination, $relative ); + if ( $item->isLink() ) { + \WP_CLI::warning( sprintf( 'Skipping symlink in starter theme: %s', $path ) ); + continue; + } + if ( $item->isDir() ) { if ( ! $this->make_directory( $target ) ) { \WP_CLI::warning( sprintf( 'Could not create directory: %s', $target ) ); @@ -262,11 +267,6 @@ private function copy_theme( string $source, string $destination ): bool { continue; } - if ( $item->isLink() ) { - \WP_CLI::warning( sprintf( 'Skipping symlink in starter theme: %s', $path ) ); - continue; - } - if ( ! $this->make_directory( dirname( $target ) ) || ! copy( $path, $target ) ) { \WP_CLI::warning( sprintf( 'Could not copy %s to %s.', $path, $target ) ); return false; From 39194d62acdcaa1f64617601bfce489c51e8109b Mon Sep 17 00:00:00 2001 From: Callin Mullaney <57088-callinmullaney@users.noreply.drupalcode.org> Date: Thu, 29 Oct 2026 11:19:43 -0500 Subject: [PATCH 094/100] fix(templates): fire get_footer before wp_footer --- .../template-extension/content-no-title-menu-empty.html | 3 ++- .../content-no-title-menu-populated.html | 3 ++- .../content-no-title-no-menu-empty.html | 3 ++- .../content-no-title-no-menu-populated.html | 3 ++- .../template-extension/content-title-menu-empty.html | 3 ++- .../template-extension/content-title-menu-populated.html | 3 ++- .../template-extension/content-title-no-menu-empty.html | 3 ++- .../content-title-no-menu-populated.html | 3 ++- .github/fixtures/template-extension/page-no-post.html | 3 ++- .github/fixtures/template-extension/page-post.html | 3 ++- .github/scripts/template-extension-smoke.php | 8 ++++++-- docs/release-notes-next.md | 3 +++ docs/template-extension.md | 6 ++++++ templates/layouts/base.twig | 3 ++- 14 files changed, 37 insertions(+), 13 deletions(-) diff --git a/.github/fixtures/template-extension/content-no-title-menu-empty.html b/.github/fixtures/template-extension/content-no-title-menu-empty.html index f1a3baa..dbcc87d 100644 --- a/.github/fixtures/template-extension/content-no-title-menu-empty.html +++ b/.github/fixtures/template-extension/content-no-title-menu-empty.html @@ -47,6 +47,7 @@ +
        - + diff --git a/.github/fixtures/template-extension/content-no-title-menu-populated.html b/.github/fixtures/template-extension/content-no-title-menu-populated.html index a6f35d5..7077e77 100644 --- a/.github/fixtures/template-extension/content-no-title-menu-populated.html +++ b/.github/fixtures/template-extension/content-no-title-menu-populated.html @@ -47,6 +47,7 @@

        Content & fixture

        +
        - + diff --git a/.github/fixtures/template-extension/content-no-title-no-menu-empty.html b/.github/fixtures/template-extension/content-no-title-no-menu-empty.html index a246245..f5957a2 100644 --- a/.github/fixtures/template-extension/content-no-title-no-menu-empty.html +++ b/.github/fixtures/template-extension/content-no-title-no-menu-empty.html @@ -23,6 +23,7 @@ +
        - + diff --git a/.github/fixtures/template-extension/content-no-title-no-menu-populated.html b/.github/fixtures/template-extension/content-no-title-no-menu-populated.html index 40cb9ac..75ae1e3 100644 --- a/.github/fixtures/template-extension/content-no-title-no-menu-populated.html +++ b/.github/fixtures/template-extension/content-no-title-no-menu-populated.html @@ -23,6 +23,7 @@

        Content & fixture

        +
        - + diff --git a/.github/fixtures/template-extension/content-title-menu-empty.html b/.github/fixtures/template-extension/content-title-menu-empty.html index 5192f87..202624c 100644 --- a/.github/fixtures/template-extension/content-title-menu-empty.html +++ b/.github/fixtures/template-extension/content-title-menu-empty.html @@ -50,6 +50,7 @@

        Page <title> & fixture

        +

        Page <title> & fixture

        - + diff --git a/.github/fixtures/template-extension/content-title-menu-populated.html b/.github/fixtures/template-extension/content-title-menu-populated.html index 7d5c97c..7227eae 100644 --- a/.github/fixtures/template-extension/content-title-menu-populated.html +++ b/.github/fixtures/template-extension/content-title-menu-populated.html @@ -50,6 +50,7 @@

        Page <title> & fixture

        Content & fixture

        +

        Page <title> & fixture

        - + diff --git a/.github/fixtures/template-extension/content-title-no-menu-empty.html b/.github/fixtures/template-extension/content-title-no-menu-empty.html index 7147296..8f77c23 100644 --- a/.github/fixtures/template-extension/content-title-no-menu-empty.html +++ b/.github/fixtures/template-extension/content-title-no-menu-empty.html @@ -26,6 +26,7 @@

        Page <title> & fixture

        +

        Page <title> & fixture

        - + diff --git a/.github/fixtures/template-extension/content-title-no-menu-populated.html b/.github/fixtures/template-extension/content-title-no-menu-populated.html index b2260d3..836219c 100644 --- a/.github/fixtures/template-extension/content-title-no-menu-populated.html +++ b/.github/fixtures/template-extension/content-title-no-menu-populated.html @@ -26,6 +26,7 @@

        Page <title> & fixture

        Content & fixture

        +

        Page <title> & fixture

        - + diff --git a/.github/fixtures/template-extension/page-no-post.html b/.github/fixtures/template-extension/page-no-post.html index 2064655..001935a 100644 --- a/.github/fixtures/template-extension/page-no-post.html +++ b/.github/fixtures/template-extension/page-no-post.html @@ -48,6 +48,7 @@

        No page content was found.

        +
        - + diff --git a/.github/fixtures/template-extension/page-post.html b/.github/fixtures/template-extension/page-post.html index de77fbb..bba3813 100644 --- a/.github/fixtures/template-extension/page-post.html +++ b/.github/fixtures/template-extension/page-post.html @@ -56,6 +56,7 @@

        Post & fixture

        +

        Post & fixture

        - + diff --git a/.github/scripts/template-extension-smoke.php b/.github/scripts/template-extension-smoke.php index 769bda8..299cd55 100644 --- a/.github/scripts/template-extension-smoke.php +++ b/.github/scripts/template-extension-smoke.php @@ -305,7 +305,8 @@ public function excerpt( array $options = array() ): string { emulsify_extension_expect( array( 73 ) === $GLOBALS['emulsify_extension_title_posts'] && false !== strpos( $password_output, '>Protected: Secret <title> & fixture' ), 'The password template must display WordPress\'s protected title while preserving title autoescaping.', $errors ); emulsify_extension_expect( false !== strpos( $password_output, '
        ' ) < strpos( $password_output, '