',
`/wp-content/themes/${themeSlug}/dist/global/smoke.css`,
`/wp-content/themes/${themeSlug}/dist/components/smoke-acf/css/smoke-acf.css`,
];
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: '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'] },
];
+ 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);
+ await checkPasswordProtection(wpPath, content, baseUrl, path.join(wpPath, 'wp-content', 'themes', themeSlug));
+ await checkTemplateRouting(wpPath, 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']));
+ 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: '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'] },
+ {
+ 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 });
+ const { verifyMediaEmbeds } = require('./media-embed-browser.cjs');
+ await verifyMediaEmbeds({ themeDir, url: `${baseUrl}/?page_id=${content.mediaPageId}`, outputDir });
+ log(`Rendered accessibility reports: ${outputDir}`);
+ }
+}
+
+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) {
@@ -768,6 +1405,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',
@@ -816,6 +1455,13 @@ 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-file', path.join(repoRoot, '.github/scripts/core-block-context-smoke.php')]);
+ wp(wpPath, ['eval-file', path.join(repoRoot, '.github/scripts/attribute-hardening-wordpress-smoke.php')]);
+ wp(wpPath, ['eval-file', path.join(repoRoot, '.github/scripts/context-wordpress-smoke.php')]);
+ run('Compare generators with real WordPress sanitization', 'npm', ['run', 'smoke:generation-parity'], {
+ env: { ...process.env, EMULSIFY_PARITY_REQUIRED: '1', EMULSIFY_WP_FORMATTING_FILE: path.join(wpPath, 'wp-includes/formatting.php') },
+ });
wp(wpPath, [
'eval',
'if ( ! class_exists( "\\\\Timber\\\\Timber" ) ) { fwrite( STDERR, "Timber is not loaded.\\n" ); exit( 1 ); }',
@@ -824,8 +1470,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, and rendered home, page, single, archive, search, author, and 404 routes.');
+ 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/.github/scripts/wordpress-starter-init-smoke.cjs b/.github/scripts/wordpress-starter-init-smoke.cjs
index d7987e6..d0b8e54 100644
--- a/.github/scripts/wordpress-starter-init-smoke.cjs
+++ b/.github/scripts/wordpress-starter-init-smoke.cjs
@@ -94,7 +94,7 @@ function runNodeScript(scriptPath) {
}
}
-function runWhiskTestScript() {
+function assertEmptyGeneratedTestFails(projectDir) {
const jestBin = path.join(
starterRoot,
'node_modules',
@@ -108,17 +108,16 @@ function runWhiskTestScript() {
);
const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm';
- const result = childProcess.spawnSync(npm, ['--prefix', 'whisk', 'run', 'test'], {
- cwd: repoRoot,
+ fs.symlinkSync(path.join(starterRoot, 'node_modules'), path.join(projectDir, 'node_modules'), 'junction');
+ const result = childProcess.spawnSync(npm, ['test', '--', '--runInBand'], {
+ cwd: projectDir,
encoding: 'utf8',
- env: process.env,
+ env: { ...process.env, CI: '1' },
+ timeout: 60000,
});
-
- if (result.status !== 0) {
- throw new Error(
- `npm --prefix whisk run test failed:\n${result.stdout || ''}${result.stderr || ''}`.trim(),
- );
- }
+ const output = `${result.stdout || ''}${result.stderr || ''}`;
+ assert(!result.error, `Generated npm test could not run: ${result.error && result.error.message}`);
+ assert(result.status !== 0 && /No tests found/.test(output), `An empty generated child theme must fail npm test because no tests exist:\n${output}`);
}
function runGeneratedComponentInspector(projectDir) {
@@ -227,6 +226,42 @@ 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.');
+ }
+
+ for (const value of [
+ 'Theme',
+ 'A
emulsify.zip.sha256
+
+ - name: Upload tested release bundle
+ if: matrix.php-version == '8.3'
+ uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
+ with:
+ name: emulsify-tested-${{ github.sha }}
+ path: |
+ dist-artifact/emulsify.zip
+ dist-artifact/emulsify.zip.sha256
+ overwrite: true
+ if-no-files-found: error
+ retention-days: 14
+
- 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
@@ -118,7 +148,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: |
@@ -133,16 +163,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
@@ -157,22 +189,25 @@ jobs:
wordpress-fixture:
name: WordPress fixture smoke (WordPress ${{ matrix.wordpress-version }})
if: >-
+ inputs.release ||
(github.event_name == 'pull_request' && (github.base_ref == 'main' || github.base_ref == 'release-2.x')) ||
github.event_name == 'push' ||
github.event_name == 'schedule' ||
(github.event_name == 'workflow_dispatch' && inputs.wordpress_fixture)
+ needs: practical
runs-on: ubuntu-latest
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
- # 6.7 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.7'
+ - wordpress-version: '6.8'
experimental: false
- wordpress-version: latest
+ experimental: false
+ - wordpress-version: nightly
experimental: true
continue-on-error: ${{ matrix.experimental }}
services:
@@ -190,16 +225,29 @@ 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
+ - 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
+ uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2.37.2
with:
php-version: '8.3'
extensions: mysqli
@@ -212,12 +260,25 @@ jobs:
- name: Install Composer dependencies
run: composer install --no-dev --no-interaction --no-progress --prefer-dist
- - name: Build installable theme archive
- run: npm run build:dist
+ - name: Audit shipped Composer dependencies
+ run: composer audit --locked --no-dev
+
+ - name: Download tested release bundle
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+ with:
+ name: emulsify-tested-${{ github.sha }}
+ path: dist-artifact
+ digest-mismatch: error
+
+ - name: Verify tested archive checksum
+ working-directory: dist-artifact
+ run: sha256sum --check emulsify.zip.sha256
- 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
@@ -227,13 +288,23 @@ jobs:
WP_SMOKE_DB_PASSWORD: root
run: npm run release:check
+ - name: Upload rendered WordPress accessibility reports
+ if: always()
+ uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
+ 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
+ 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
@@ -241,6 +312,7 @@ jobs:
extended-whisk:
name: Extended Whisk Storybook and a11y
if: >-
+ inputs.release ||
(github.event_name == 'pull_request' && (github.base_ref == 'main' || github.base_ref == 'release-2.x')) ||
github.event_name == 'push' ||
github.event_name == 'schedule' ||
@@ -249,37 +321,61 @@ 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
+ - 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
- run: npm run whisk:install
+ env:
+ PUPPETEER_SKIP_DOWNLOAD: 'true'
+ # Keep this job's resolved tree for the informational audit below.
+ run: npm run whisk:install -- --package-lock=true
+
+ - name: Audit Whisk dependencies
+ continue-on-error: true
+ run: npm --prefix whisk audit
- 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@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
+ 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
+ uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: extended-whisk-storybook
path: |
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
diff --git a/.husky/commit-msg b/.husky/commit-msg
new file mode 100644
index 0000000..70bd3dd
--- /dev/null
+++ b/.husky/commit-msg
@@ -0,0 +1 @@
+npx --no-install commitlint --edit "$1"
diff --git a/.husky/pre-commit b/.husky/pre-commit
index 20d0d06..3867a0f 100755
--- a/.husky/pre-commit
+++ b/.husky/pre-commit
@@ -1,4 +1 @@
-#!/bin/sh
-. "$(dirname "$0")/_/husky.sh"
-
npm run lint
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..afc4778
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,83 @@
+# 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 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
+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 6ab6cc6..fec0467 100644
--- a/README.md
+++ b/README.md
@@ -1,8 +1,8 @@
-
+
# 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.
@@ -31,10 +31,10 @@ 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`.
+- 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.
@@ -89,7 +89,7 @@ npm install
| `npm run audit:twig-stories` | Check Twig story compatibility. |
| `npm run inspect:components` | Discover components and report metadata, dependencies, configuration issues, and orphaned files. |
| `npm run a11y` | Build Storybook and run the Core accessibility check. |
-| `npm run test` | Run Jest with `--passWithNoTests` for starter projects. |
+| `npm run test` | Run project Jest tests with coverage; fails when no tests exist. |
The component inspector comes from the installed `@emulsify/core` package and
runs from the generated child theme root without requiring WordPress to be
@@ -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.
@@ -147,7 +147,10 @@ npm run lint:php
`lint:php` runs PHPCS with `phpcs.xml.dist`, followed by PHPStan with `phpstan.neon.dist`. Both configurations cover `includes/` and the parent theme's root PHP entry points, including `functions.php`. Use `npm run lint:php:fix` to apply safe PHPCBF coding-standard fixes, then rerun `npm run lint:php` to confirm PHPStan and the remaining PHPCS checks.
-The `.husky/pre-commit` hook already runs `npm run lint`, which delegates to this PHP check.
+The `.husky/pre-commit` hook runs `npm run lint`, which delegates to this PHP
+check. A normal `npm install` installs Husky 9 hooks in clones and worktrees.
+After installing with `npm ci --ignore-scripts`, enable local hooks with
+`npx --no-install husky`.
| Command | Purpose |
| --- | --- |
@@ -155,9 +158,16 @@ The `.husky/pre-commit` hook already runs `npm run lint`, which delegates to thi
| `npm run pr:check` | Run the practical, stubbed pull request validation suite. |
| `npm run release:check` | Run release-readiness checks. |
| `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. |
+| `npm run publish-test -- --no-ci` | Run a local release dry run after meeting the [prerequisites](docs/release-process.md#local-release-dry-runs). |
-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 PHP 8.3 practical lane builds `emulsify.zip`; the WordPress jobs verify and extract that same archive, confirm its packaged generator and Whisk source, and set `WP_SMOKE_REQUIRED=1`, so missing WP-CLI/MySQL prerequisites or route render failures fail the job instead of producing a skip. They also audit seven actual WordPress routes, including YouTube and Vimeo posters, with explicit WCAG 2.2 AA axe tags and save 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.
@@ -183,6 +193,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)
@@ -190,6 +206,8 @@ 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)
+- [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)
@@ -212,6 +230,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).
@@ -225,7 +246,9 @@ This repository uses [Conventional Commits](https://www.conventionalcommits.org/
- `feat!:` or a `BREAKING CHANGE:` footer produces a major release.
- `docs:`, `test:`, `ci:`, and `chore:` produce no release.
-A commit-msg hook validates the format with commitlint. See [docs/release-process.md](docs/release-process.md) for the full version strategy.
+The `.husky/commit-msg` hook validates the format with commitlint after local
+hooks are installed. See [docs/release-process.md](docs/release-process.md) for
+the full version strategy.
### Release readiness
diff --git a/UPGRADE.md b/UPGRADE.md
index 1659dff..d3ffa14 100644
--- a/UPGRADE.md
+++ b/UPGRADE.md
@@ -4,6 +4,305 @@ 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
+
+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. `@components` now uses that same
+ grouped lookup: a grouped child source component can win over a direct file
+ in a later legacy or parent root. Use an explicit group or template path
+ where a project needs one particular component.
+ If `variant.structureImplementations` is configured, its `components` entry,
+ or its first accepted entry when none is named `components`, replaces the
+ default child component roots. Move components or use an explicit registered
+ namespace if includes relied on `src/components` or `components` staying
+ available alongside the override. Parent fallbacks remain available.
+ Implementation names are trimmed and lowercased; use plain identifiers
+ without an `@` prefix. Unnamed entries receive `structure-N` names.
+ Unregistered explicit prefixes now fall back to component lookup as in Core;
+ registered namespaces retain their scope. Literal prefixed component files
+ are tried before removing an unregistered prefix, matching Core. Registered
+ namespace colon aliases now use their named namespace. The `components:...`,
+ `components::...`, and `components/...` aliases now target component lookup;
+ use `@templates/components/...` for a controller template under that path.
+ Custom loaders need `getNamespaces()` and `getPaths()` to support this
+ integration; opaque loaders keep their native behavior.
+ Other bare paths keep every segment and stay with WordPress's normal Twig
+ loader. 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:
+ - Move separate posts-page markup from `front-page.twig` to `home.twig`.
+ `front-page.twig` now serves only the front page, whether it shows posts or
+ a static page, ahead of `home.twig` or `page.twig`. Protected static front
+ pages still use the password template until the password is accepted.
+ - Mapped core-block templates receive a cached global Timber context and a
+ fresh block-specific `post`, without setting up WordPress's global post.
+ Add any required route-specific `posts`, `term`, `author` or `search_query`
+ values through the block context filter. Use `content` for current-block
+ HTML, rather than `post.content`. For blocks with children, `inner_content`
+ now equals the full rendered `content`, including its saved wrapper; print
+ one of them once. See [mapped block context](docs/core-block-twig-rendering.md#twig-context).
+ - `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`.
+ 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.
+ - `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. Keep `get_footer` before the footer block
+ and `wp_footer` afterward when copying the base layout.
+ - 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 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).
+ 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. 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
+ `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).
+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).
+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).
+
+8. **Review values passed to `bem()` and `add_attributes()`.** The PHP bag now
+ rejects `srcdoc`, all Alpine `x-*` and Vue `v-*` directives, and the
+ documented htmx expression-bearing families (including `data-hx-*` aliases).
+ Leading `:` bindings, native `on*` and `@` handlers are also rejected. URL
+ attributes use WordPress's allowed protocols; default policy strips
+ `javascript:` and `data:`. Replace data-URI image sources with permitted
+ URLs. Pass attribute maps as arrays, Traversables or `AttributeBag`, rather
+ 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. Standalone display names and descriptions must also be plain
+ text without `<`; WordPress retains its own HTML sanitizer. See the [generation contract](docs/generated-child-theme-contract.md).
+
+10. **Cast lazy context URLs in PHP consumers.** `wp.logout_url` remains
+ available to Twig but is now a serializable stringable value to avoid
+ creating an unused nonce. PHP code passing it to strict string APIs should
+ use `(string) $context['wp']['logout_url']`. Output-cache JSON serialization
+ still materializes the URL to preserve the cache key's nonce variation.
+
+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": {
+- "@emulsify/core": "^4.3.2"
++ "@emulsify/core": "^4.5.0"
+ }
+```
+
+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
+
+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": {
+- "lint": "npm run lint-js; npm run lint-styles",
+- "lint-fix": "npm run lint-js -- --fix; npm run lint-styles -- --fix",
+- "format": "npm run lint-fix; npm run prettier-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'",
++ "format": "sh -c 'status=0; npm run lint-fix || status=$?; npm run prettier-fix || status=$?; exit $status'"
+ }
+```
+
+#### Adopt the Core accessibility runner
+
+The accessibility command stays the same before and after:
+
+```json
+{
+ "scripts": {
+ "a11y": "npm run storybook-build && node config/emulsify-core/run-a11y.js"
+ }
+}
+```
+
+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 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.
+
+#### 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": {
+- "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"
+ }
+```
+
+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. 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 verifies GitHub publication in CI before skipping.
+Use the [local dry-run requirements](docs/release-process.md#local-release-dry-runs)
+for pushed branches and token setup. 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
2.0.0 is a full rebuild of the WordPress theme around Timber, Emulsify Core 4,
@@ -13,10 +312,10 @@ 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
+- 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.
@@ -45,7 +344,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/archive.php b/archive.php
index b20c875..eff13f3 100644
--- a/archive.php
+++ b/archive.php
@@ -18,7 +18,7 @@
$page_title = __( 'Archive', 'emulsify' );
if ( is_day() ) {
/* translators: %s is the archive date. */
- $page_title = sprintf( __( 'Archive: %s', 'emulsify' ), get_the_date( 'D M Y' ) );
+ $page_title = sprintf( __( 'Archive: %s', 'emulsify' ), get_the_date( (string) get_option( 'date_format' ) ) );
} elseif ( is_month() ) {
/* translators: %s is the archive month and year. */
$page_title = sprintf( __( 'Archive: %s', 'emulsify' ), get_the_date( 'M Y' ) );
@@ -31,9 +31,14 @@
$page_title = single_cat_title( '', false );
} elseif ( is_post_type_archive() ) {
$page_title = post_type_archive_title( '', false );
- // Allow child themes to provide archive-{post_type}.twig before the generic
- // archive fallback without adding more PHP template files.
- array_unshift( $templates, '@templates/archive-' . get_post_type() . '.twig' );
+ // Like WordPress' archive hierarchy, only a single queried type gets its
+ // own template. This also works when the archive has no current post.
+ $archive_post_types = array_filter( (array) get_query_var( 'post_type' ) );
+ $archive_post_type = count( $archive_post_types ) === 1 ? reset( $archive_post_types ) : '';
+
+ if ( is_string( $archive_post_type ) && '' !== $archive_post_type ) {
+ array_unshift( $templates, '@templates/archive-' . $archive_post_type . '.twig' );
+ }
}
$context = Timber::context(
diff --git a/docs/README.md b/docs/README.md
index efedea1..23a2baf 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -3,14 +3,19 @@
## 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)
- [Generated child theme contract](generated-child-theme-contract.md)
+- [Dependency audit: October 23, 2026](dependency-audit-2026-10-23.md)
## Authoring
- [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)
@@ -29,10 +34,13 @@
- [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
-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 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
new file mode 100644
index 0000000..c6ae314
--- /dev/null
+++ b/docs/accessibility.md
@@ -0,0 +1,158 @@
+# Accessibility checks
+
+The parent theme supplies semantic fallback markup and WordPress integration.
+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
+
+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%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,
+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.
+
+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,
+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 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.
+
+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/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/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-4-vite-workflow.md b/docs/core-4-vite-workflow.md
index 644a70c..8d7e1b6 100644
--- a/docs/core-4-vite-workflow.md
+++ b/docs/core-4-vite-workflow.md
@@ -60,7 +60,7 @@ Run these from the generated child theme directory:
| `npm run audit:twig-stories` | Check Twig story compatibility. |
| `npm run inspect:components` | Discover components and report metadata, dependencies, configuration issues, and orphaned files. |
| `npm run a11y` | Build Storybook and run the Core accessibility check. |
-| `npm run test` | Run Jest with `--passWithNoTests` for starter projects. |
+| `npm run test` | Run project Jest tests with coverage; fails when no tests exist. |
### Component inspector
diff --git a/docs/core-block-twig-rendering.md b/docs/core-block-twig-rendering.md
index 284bf27..059d620 100644
--- a/docs/core-block-twig-rendering.md
+++ b/docs/core-block-twig-rendering.md
@@ -26,22 +26,50 @@ add_filter(
);
```
-Mapped template paths are child/parent theme relative. The active child theme is checked first, then the parent theme. If a block is not mapped, or the mapped Twig file is not readable, WordPress' original rendered block content is returned unchanged.
+Mapped template paths are child/parent theme relative. The active child theme is checked first, then the parent theme. If a block is not mapped, or the mapped Twig file is not a readable file, WordPress' original rendered block content is returned unchanged.
## Twig context
-Mapped templates receive the normal Timber context plus these values:
+Mapped templates receive Timber's global context plus these values:
+- `post`: the Timber post identified by the block context's `postId`, or the
+ current global WordPress post when no `postId` was supplied. An explicitly
+ missing or invalid `postId` produces `null`.
- `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_content`: saved string fragments for a leaf block; the full rendered
+ `content` when fragments contain child placeholders or no usable text.
- `inner_blocks`: parsed inner block records when available.
- `wp_block`: the `WP_Block` instance when WordPress passes one.
- `twig_template`: the mapped Twig template.
+`inner_content` includes the saved block's wrapper. For a group, quote or other
+block containing children, it deliberately equals `content`, including every
+child's final rendered output. Children are never rendered again. Capturing
+generic `render_block` output would miss later block-name filters and children
+replaced through `pre_render_block`, so the renderer uses WordPress's assembled
+HTML. Print either `content` or `inner_content` once; printing both duplicates
+the block. Neither value promises wrapper-free children for a replacement
+component wrapper.
+
+The shared renderer builds its global base once during the request with
+`Timber::context_global()`. It creates each block's `post` without calling
+`setup()`, preserving Query Loop globals and avoiding extra `the_post` actions.
+Route-specific values that `Timber::context()` adds, such as `posts`, `term`,
+`author` or `search_query`, are not added automatically; supply them through the
+block context filter when needed.
+
+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 +77,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;
@@ -59,6 +87,11 @@ add_filter(
);
```
+The context and template-selection filters still run for every mapped block.
+The template-map filter also runs for each block, preserving dynamic callbacks
+and filters added or removed after rendering starts. Only normalization of an
+unchanged map result is cached; callback results are not frozen for the request.
+
## Optional class cleanup
The renderer does not strip `wp-block` classes by default. If a project needs class cleanup, opt in separately:
@@ -73,6 +106,12 @@ Cleanup uses `WP_HTML_Tag_Processor` when available and otherwise leaves Twig ou
If a mapped template throws an exception or Timber is unavailable, normal frontend visitors receive WordPress' original block output. Diagnostics are only appended for users who can edit posts or edit theme options, and debug logging only runs when `WP_DEBUG` is enabled.
+If Twig or a mapping/context filter re-enters mapped block rendering, that
+nested call returns WordPress's original markup. This bounds recursion from a
+template that prints `post.content`. Use `content` for the current block instead.
+Ordinary parsed inner blocks have already rendered before the parent template
+runs and are unaffected by this guard.
+
## Filters
- `emulsify_theme_core_block_twig_rendering_enabled`
diff --git a/docs/dependency-audit-2026-10-23.md b/docs/dependency-audit-2026-10-23.md
new file mode 100644
index 0000000..517fc59
--- /dev/null
+++ b/docs/dependency-audit-2026-10-23.md
@@ -0,0 +1,142 @@
+# 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.
+
+## 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/docs/generated-child-theme-contract.md b/docs/generated-child-theme-contract.md
index 42c54ce..47004d0 100644
--- a/docs/generated-child-theme-contract.md
+++ b/docs/generated-child-theme-contract.md
@@ -11,21 +11,43 @@ 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.
-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`.
+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. For accepted plain-text input, the only expected difference is the lockfile the standalone path creates during `npm install`.
## 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.
- `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`.
- 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. Accepted plain-text labels and descriptions remove
+ percent-encoded octets consistently. Standalone init rejects `<` in either
+ field before writing; WordPress retains its native HTML stripping. Supply
+ plain text to Emulsify CLI rather than depending on PHP's tag parser.
## Outside the contract
@@ -52,6 +74,26 @@ 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
+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
@@ -66,6 +108,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/media-embeds.md b/docs/media-embeds.md
new file mode 100644
index 0000000..e7cb387
--- /dev/null
+++ b/docs/media-embeds.md
@@ -0,0 +1,172 @@
+# 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;
+ }
+);
+```
+
+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()`. String post IDs must contain only decimal
+digits and fit a positive PHP integer; whitespace and a leading `+` are rejected.
+
+| 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. Their images use native lazy loading and async
+decoding, but the browser decides when to fetch them; lazy loading is not a
+privacy guarantee. 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; playlist players use a
+neutral poster because `videoseries` is not a video ID. Vimeo posters use
+cached `thumbnail_url` metadata captured from WordPress oEmbed responses only
+when click to load and provider posters are enabled. For that metadata capture,
+the options filter receives the original URL, `array()` attributes, and a
+`null` post ID; filters that depend on a post must handle this context too.
+
+On a Vimeo 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. A separate one-hour retry marker
+backs off failed or overlapping lookups without replacing valid cached
+metadata with an empty entry. 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` | 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 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.
+The parent preserves the rendered document exactly across supported WordPress
+HTML API versions, including nested entities inside single-quoted attributes.
+Keep autoescaping enabled even with that outer `srcdoc` boundary.
+
+If Timber is missing, an override is empty or invalid, or poster rendering
+throws, the embed becomes an escaped plain link to the original video with its
+title as the link text. It contains no iframe or thumbnail, so it makes no
+provider request before the visitor follows it. The parent reports the failure
+through WordPress's `_doing_it_wrong()` diagnostic action. With `WP_DEBUG` off,
+the request continues; with `WP_DEBUG` on, WordPress also warns and the original
+exception is rethrown so developers can fix the override. This behavior applies
+to embed filtering in frontend, REST, and editor content rendering. The theme's
+separate missing-Timber frontend error page still applies; this safeguard keeps
+admin and REST requests from failing inside the embed filter too.
+
+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. Other provider
+permissions without an explicit origin list receive the same origins;
+directives with an explicit list keep it. 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:
+
+- Allow the selected YouTube player host in `frame-src`:
+ `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. The initial blank/`srcdoc` poster does not require
+ adding `'self'` to `frame-src`.
+- 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 %}
+
+
+
diff --git a/templates/partials/pagination.twig b/templates/partials/pagination.twig
index e3927ec..68a0fe8 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 }}
+ {{ function('__', 'Page', 'emulsify') }} {{ function('html_entity_decode', page.title) }}
{% else %}
- {{ page.title }}
+ {{ page.class|default('') == 'dots' ? function('__', '…', 'emulsify') : function('html_entity_decode', 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') }}
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) %}
{{
post.excerpt({
diff --git a/templates/partials/tease.twig b/templates/partials/tease.twig
index ff19254..3b5bd40 100644
--- a/templates/partials/tease.twig
+++ b/templates/partials/tease.twig
@@ -18,19 +18,19 @@
{% block media %}
{% if post.thumbnail|default(null) and post.thumbnail.src|default(null) %}
-
+
{% endif %}
{% endblock %}
{% block title %}
{% endblock %}
{% block summary %}
- {% if post.excerpt|default(null) %}
+ {% if not post.password_required and post.excerpt|default(null) %}
{{ post.excerpt|raw }}
{% 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) %}
-
+ {{ function('get_the_password_form', post.id)|raw }}
{% else %}
{{ function('__', 'This content is password protected.', 'emulsify') }}
{% endif %}
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 @@
{% if post.thumbnail|default(null) and post.thumbnail.src|default(null) %}
-
+
{% endif %}
- {{ post.title }}
+ {{ post.title|wp_kses_post }}
{% if post.author|default(null) or post.date|default(null) %}
{% if post.author|default(null) %}
- {{ post.author.name }}
+ {{ post.author.name|esc_html }}
{% endif %}
diff --git a/theme.json b/theme.json
index 97101d9..057408c 100644
--- a/theme.json
+++ b/theme.json
@@ -523,29 +523,11 @@
"elements": {
"link": {
"border": {},
- "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": {}
},
@@ -608,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": {
@@ -624,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"
}
},
@@ -652,7 +627,7 @@
},
"spacing": {
"margin": {
- "top": "var(--wp--preset--spacing--medium)"
+ "top": "0"
}
}
}
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/.cli/init.js b/whisk/.cli/init.js
index f1ffb60..268b5b5 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,17 @@ const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
// would break the surrounding structure.
const oneLine = (value) => String(value ?? '').replace(/\s+/g, ' ').trim();
+// WordPress removes percent-encoded octets from plain-text metadata. Match that
+// rule here; HTML is rejected rather than approximating PHP's strip_tags().
+const sanitizeTextField = (value) => {
+ const trim = (text) => text.replace(/^[\0\t\n\v\r ]+|[\0\t\n\v\r ]+$/g, '');
+ let text = trim(String(value).replace(/[\r\n\t ]+/g, ' '));
+ while (/%[a-f0-9]{2}/i.test(text)) {
+ text = trim(text.replace(/%[a-f0-9]{2}/gi, '').replace(/ +/g, ' '));
+ }
+ return text;
+};
+
const readThemeHeader = (contents, field) => {
const match = contents.match(
new RegExp(`^\\s*\\*\\s*${escapeRegExp(field)}:\\s*(.+?)\\s*$`, 'mi'),
@@ -94,6 +116,18 @@ const getProjectConfig = () => {
throw new Error('project.emulsify.json project.machineName must be a non-empty string.');
}
+ for (const [field, value] of Object.entries({ name, description: config.project.description })) {
+ if (typeof value === 'string' && value.includes('<')) {
+ throw new Error(`project.${field} must be plain text without '<'. Remove HTML and re-run Emulsify CLI; this hook does not emulate WordPress's HTML stripping.`);
+ }
+ }
+
+ 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 +157,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(
@@ -243,22 +278,12 @@ 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 [];
}
- 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()) {
@@ -269,10 +294,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)}`;
}
@@ -285,9 +308,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),
@@ -303,8 +329,7 @@ const main = () => {
updateLockfile('npm-shrinkwrap.json', project);
updateProjectConfig(config, project);
updateFunctionsPhp(project);
- updatePageTemplate(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
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 85fe783..660c3cf 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
@@ -34,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/config/emulsify-core/run-a11y.js b/whisk/config/emulsify-core/run-a11y.js
index 8bed5f3..47b7189 100644
--- a/whisk/config/emulsify-core/run-a11y.js
+++ b/whisk/config/emulsify-core/run-a11y.js
@@ -1,20 +1,18 @@
#!/usr/bin/env node
-import fs from 'node:fs';
-import http from 'node:http';
-import path from 'node:path';
+import { pathToFileURL } from 'node:url';
-import pa11y from 'pa11y';
-
-import a11yConfig from './a11y.config.js';
-import coreA11yConfig from '../../node_modules/@emulsify/core/config/a11y.config.js';
import {
applyProjectA11yConfig,
- logReport,
+ lintReportAndExit,
resolvePa11yStoryIds,
- resolveStorybookBuildDir,
+ resolveProjectA11yConfig,
+ startStorybookServer,
} from '../../node_modules/@emulsify/core/scripts/a11y.js';
+const { default: a11yConfig } = await import(
+ pathToFileURL(resolveProjectA11yConfig()).href
+);
applyProjectA11yConfig(a11yConfig);
const storyIds = resolvePa11yStoryIds();
@@ -25,105 +23,12 @@ if (storyIds.length === 0) {
);
}
-const buildDirectory = resolveStorybookBuildDir();
-const contentTypes = {
- '.css': 'text/css; charset=utf-8',
- '.gif': 'image/gif',
- '.html': 'text/html; charset=utf-8',
- '.ico': 'image/x-icon',
- '.jpeg': 'image/jpeg',
- '.jpg': 'image/jpeg',
- '.js': 'text/javascript; charset=utf-8',
- '.json': 'application/json; charset=utf-8',
- '.map': 'application/json; charset=utf-8',
- '.png': 'image/png',
- '.svg': 'image/svg+xml',
- '.webp': 'image/webp',
- '.woff': 'font/woff',
- '.woff2': 'font/woff2',
-};
-
-const server = http.createServer((request, response) => {
- 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 5e4da05..0a3f2df 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.
@@ -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
@@ -89,7 +115,50 @@ 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:
+
+```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
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/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
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() }}
+
+{% 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.
diff --git a/whisk/package.json b/whisk/package.json
index 787012a..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",
@@ -18,16 +18,16 @@
"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",
"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,12 +35,12 @@
"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": {
- "@emulsify/core": "^4.3.2"
+ "@emulsify/core": "^4.5.0"
},
"overrides": {
"glob": "^13.0.6",
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
*/
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 %}
-
- {{ parent() }}
-
-{% endblock %}