diff --git a/.browserslistrc b/.browserslistrc
index 0a3af40..4abb119 100644
--- a/.browserslistrc
+++ b/.browserslistrc
@@ -1,7 +1,8 @@
# Browsers that we support
-last 1 version
> 1%
-maintained node versions
+last 2 versions
+Firefox ESR
+Chrome 41 # Support for Googlebot
not dead
-ie 11
+not IE 9-11 # For IE 9-11 support, remove 'not'.
diff --git a/.eslintrc.yml b/.eslintrc.yml
deleted file mode 100644
index d780b54..0000000
--- a/.eslintrc.yml
+++ /dev/null
@@ -1,35 +0,0 @@
-extends:
- - airbnb
- - prettier
- - plugin:prettier/recommended
-plugins:
- - react
- - prettier
-env:
- es6: true
- browser: true
-globals:
- expect: true
- it: true
- describe: true
- Attach: true
-parser: babel-eslint
-rules:
- strict: 0
- react/jsx-filename-extension: [1, { 'extensions': ['.js', '.jsx'] }]
- import/no-extraneous-dependencies: 0
- react/prefer-stateless-function: 0
- prettier/prettier: error
- jsx-a11y/label-has-for:
- - 2
- - required:
- some:
- - nesting
- - id
-ignorePatterns:
- - '**/*.min.js'
-overrides:
- - files:
- - '*.stories.js'
- rules:
- react/no-danger: 0
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..59927d5
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,22 @@
+/.gitattributes export-ignore
+/.github export-ignore
+/.github/** export-ignore
+/.gitignore export-ignore
+/.husky export-ignore
+/.husky/** export-ignore
+/.browserslistrc export-ignore
+/.editorconfig export-ignore
+/.nvmrc export-ignore
+/CODE_OF_CONDUCT.md export-ignore
+/commitlint.config.js export-ignore
+/composer.lock export-ignore
+/husky.config.js export-ignore
+/package-lock.json export-ignore
+/package.json export-ignore
+/phpcs.xml.dist export-ignore
+/phpstan.neon.dist export-ignore
+/release.config.js export-ignore
+/scripts export-ignore
+/scripts/** export-ignore
+/whisk/.cli export-ignore
+/whisk/.cli/** export-ignore
diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md
new file mode 100644
index 0000000..6ce0342
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE.md
@@ -0,0 +1,11 @@
+Emulsify WordPress version (see [releases](https://github.com/emulsify-ds/emulsify-wordpress/releases)):
+
+**What you did:**
+
+**What happened:**
+
+**Reproduction repository (if necessary):**
+
+**Problem description:**
+
+**Suggested solution:**
diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
new file mode 100644
index 0000000..e5b1c62
--- /dev/null
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -0,0 +1,13 @@
+**This PR does the following:**
+- Adds functionality bullet item
+- Fixes this or that bullet item
+
+### Related Issue(s)
+- [Title of the issue](https://github.com/emulsify-ds/emulsify-wordpress/issues/1) (if applicable)
+
+### Notes:
+- (optional) Document any intentionally unfinished parts or known issues within this PR
+
+### Functional Testing:
+- [ ] Document steps that allow someone to fully test your code changes. Include screenshot and links when appropriate.
+- [ ] Confirm `Practical theme readiness`, `PHP coding standards and static analysis`, `WordPress fixture smoke`, and `Extended Whisk Storybook and a11y` pass for changes targeting the protected release branches.
diff --git a/.github/fixtures/whisk-a11y/ci-readiness/ci-readiness.js b/.github/fixtures/whisk-a11y/ci-readiness/ci-readiness.js
new file mode 100644
index 0000000..e6cf601
--- /dev/null
+++ b/.github/fixtures/whisk-a11y/ci-readiness/ci-readiness.js
@@ -0,0 +1,4 @@
+/**
+ * Harmless Vite entry used only by the CI-seeded Whisk readiness fixture.
+ */
+export const ciReadinessFixture = true;
diff --git a/.github/fixtures/whisk-a11y/ci-readiness/ci-readiness.stories.js b/.github/fixtures/whisk-a11y/ci-readiness/ci-readiness.stories.js
new file mode 100644
index 0000000..b2d1ee5
--- /dev/null
+++ b/.github/fixtures/whisk-a11y/ci-readiness/ci-readiness.stories.js
@@ -0,0 +1,16 @@
+import template from './ci-readiness.twig';
+
+export default {
+ title: 'CI/Readiness fixture',
+};
+
+export const Default = {
+ render: (args) => template(args),
+ args: {
+ title: 'CI accessibility readiness',
+ summary:
+ 'This component gives the component-agnostic starter one real story during continuous integration.',
+ url: 'https://www.emulsify.info',
+ link_text: 'Visit the Emulsify documentation',
+ },
+};
diff --git a/.github/fixtures/whisk-a11y/ci-readiness/ci-readiness.twig b/.github/fixtures/whisk-a11y/ci-readiness/ci-readiness.twig
new file mode 100644
index 0000000..b10fe4b
--- /dev/null
+++ b/.github/fixtures/whisk-a11y/ci-readiness/ci-readiness.twig
@@ -0,0 +1,7 @@
+
+
+ {{ title }}
+ {{ summary }}
+ {{ link_text }}
+
+
diff --git a/.github/scripts/acf-local-json-smoke.php b/.github/scripts/acf-local-json-smoke.php
new file mode 100644
index 0000000..bcf3c19
--- /dev/null
+++ b/.github/scripts/acf-local-json-smoke.php
@@ -0,0 +1,216 @@
+ $accepted_args,
+ 'callback' => $callback,
+ );
+
+ ksort( $GLOBALS['emulsify_acf_json_smoke_filters'][ $hook ] );
+
+ return true;
+ }
+}
+
+if ( ! function_exists( 'apply_filters' ) ) {
+ function apply_filters( string $hook, $value, ...$arguments ) {
+ if ( empty( $GLOBALS['emulsify_acf_json_smoke_filters'][ $hook ] ) ) {
+ return $value;
+ }
+
+ foreach ( $GLOBALS['emulsify_acf_json_smoke_filters'][ $hook ] as $callbacks ) {
+ foreach ( $callbacks as $callback ) {
+ $value = call_user_func_array(
+ $callback['callback'],
+ array_slice(
+ array_merge( array( $value ), $arguments ),
+ 0,
+ $callback['accepted_args']
+ )
+ );
+ }
+ }
+
+ return $value;
+ }
+}
+
+if ( ! function_exists( 'get_stylesheet_directory' ) ) {
+ function get_stylesheet_directory(): string {
+ return $GLOBALS['emulsify_acf_json_smoke_child'];
+ }
+}
+
+/**
+ * Fails the smoke script when an assertion is false.
+ *
+ * @param bool $condition Assertion condition.
+ * @param string $message Failure message.
+ * @return void
+ */
+function emulsify_acf_json_smoke_assert( bool $condition, string $message ): void {
+ if ( ! $condition ) {
+ throw new RuntimeException( $message );
+ }
+}
+
+/**
+ * Recursively removes a path.
+ *
+ * @param string $path Path to remove.
+ * @return void
+ */
+function emulsify_acf_json_smoke_remove( string $path ): void {
+ if ( ! file_exists( $path ) ) {
+ return;
+ }
+
+ if ( is_file( $path ) || is_link( $path ) ) {
+ unlink( $path );
+ return;
+ }
+
+ $iterator = new RecursiveIteratorIterator(
+ new RecursiveDirectoryIterator( $path, RecursiveDirectoryIterator::SKIP_DOTS ),
+ RecursiveIteratorIterator::CHILD_FIRST
+ );
+
+ foreach ( $iterator as $item ) {
+ $item->isDir() && ! $item->isLink() ? rmdir( $item->getPathname() ) : unlink( $item->getPathname() );
+ }
+
+ rmdir( $path );
+}
+
+$repo_root = dirname( __DIR__, 2 );
+$work_root = sys_get_temp_dir() . '/emulsify-acf-local-json-' . uniqid( '', true );
+$child = $work_root . '/child-theme';
+$default = $child . '/config/acf-json';
+$custom = $work_root . '/custom-acf-json';
+$extra = $work_root . '/extra-acf-json';
+
+$GLOBALS['emulsify_acf_json_smoke_child'] = $child;
+
+try {
+ require_once $repo_root . '/includes/Acf/LocalJson.php';
+
+ $inactive = new Emulsify\Theme\Acf\LocalJson();
+ $inactive->register();
+
+ emulsify_acf_json_smoke_assert(
+ empty( $GLOBALS['emulsify_acf_json_smoke_filters']['acf/settings/save_json'] )
+ && empty( $GLOBALS['emulsify_acf_json_smoke_filters']['acf/settings/load_json'] ),
+ 'ACF Local JSON service should not register ACF filters when ACF is unavailable.'
+ );
+
+ if ( ! function_exists( 'acf' ) ) {
+ function acf(): bool {
+ return true;
+ }
+ }
+
+ $service = new Emulsify\Theme\Acf\LocalJson();
+ $service->register();
+
+ emulsify_acf_json_smoke_assert(
+ isset( $GLOBALS['emulsify_acf_json_smoke_filters']['acf/settings/save_json'] )
+ && isset( $GLOBALS['emulsify_acf_json_smoke_filters']['acf/settings/load_json'] ),
+ 'ACF Local JSON service should register ACF filters when ACF is available.'
+ );
+
+ $incoming_save = '/acf/default-save';
+ $incoming_load = array( '/acf/default-load' );
+
+ emulsify_acf_json_smoke_assert(
+ $incoming_save === apply_filters( 'acf/settings/save_json', $incoming_save ),
+ 'ACF Local JSON save path should no-op when config/acf-json is missing.'
+ );
+ emulsify_acf_json_smoke_assert(
+ $incoming_load === apply_filters( 'acf/settings/load_json', $incoming_load ),
+ 'ACF Local JSON load paths should no-op when config/acf-json is missing.'
+ );
+
+ if ( ! mkdir( $default, 0777, true ) || ! mkdir( $custom, 0777, true ) || ! mkdir( $extra, 0777, true ) ) {
+ throw new RuntimeException( 'Could not create ACF JSON smoke fixture directories.' );
+ }
+
+ emulsify_acf_json_smoke_assert(
+ $default === apply_filters( 'acf/settings/save_json', $incoming_save ),
+ 'ACF Local JSON save path should use child config/acf-json by default.'
+ );
+ emulsify_acf_json_smoke_assert(
+ array( '/acf/default-load', $default ) === apply_filters( 'acf/settings/load_json', $incoming_load ),
+ 'ACF Local JSON load paths should keep the default path and add child config/acf-json.'
+ );
+
+ add_filter(
+ 'emulsify_theme_acf_json_remove_default_load_path',
+ static function (): bool {
+ return true;
+ }
+ );
+
+ emulsify_acf_json_smoke_assert(
+ array( $default ) === apply_filters( 'acf/settings/load_json', $incoming_load ),
+ 'ACF Local JSON should remove the default load path only when explicitly configured.'
+ );
+
+ add_filter(
+ 'emulsify_theme_acf_json_save_path',
+ static function () use ( $custom ): string {
+ return $custom;
+ }
+ );
+
+ add_filter(
+ 'emulsify_theme_acf_json_load_paths',
+ static function ( array $paths ) use ( $extra ): array {
+ $paths[] = $extra;
+
+ return $paths;
+ }
+ );
+
+ emulsify_acf_json_smoke_assert(
+ $custom === apply_filters( 'acf/settings/save_json', $incoming_save ),
+ 'ACF Local JSON save path filter should override the default save path.'
+ );
+ emulsify_acf_json_smoke_assert(
+ array( $custom, $extra ) === apply_filters( 'acf/settings/load_json', $incoming_load ),
+ 'ACF Local JSON load paths filter should alter final load paths.'
+ );
+
+ add_filter(
+ 'emulsify_theme_acf_json_enabled',
+ static function (): bool {
+ return false;
+ },
+ 1
+ );
+
+ emulsify_acf_json_smoke_assert(
+ $incoming_save === apply_filters( 'acf/settings/save_json', $incoming_save )
+ && $incoming_load === apply_filters( 'acf/settings/load_json', $incoming_load ),
+ 'ACF Local JSON enabled filter should disable all path changes.'
+ );
+
+ echo "ACF Local JSON smoke checks passed.\n";
+} catch ( Throwable $throwable ) {
+ fwrite( STDERR, $throwable->getMessage() . "\n" );
+ exit( 1 );
+} finally {
+ emulsify_acf_json_smoke_remove( $work_root );
+}
diff --git a/.github/scripts/asset-manifest-smoke.php b/.github/scripts/asset-manifest-smoke.php
new file mode 100644
index 0000000..fa79faf
--- /dev/null
+++ b/.github/scripts/asset-manifest-smoke.php
@@ -0,0 +1,493 @@
+ $accepted_args,
+ 'callback' => $callback,
+ );
+
+ ksort( $GLOBALS['emulsify_asset_manifest_smoke_hooks'][ $hook ] );
+
+ return true;
+ }
+}
+
+if ( ! function_exists( 'apply_filters' ) ) {
+ function apply_filters( string $hook, $value, ...$arguments ) {
+ if ( empty( $GLOBALS['emulsify_asset_manifest_smoke_hooks'][ $hook ] ) ) {
+ return $value;
+ }
+
+ foreach ( $GLOBALS['emulsify_asset_manifest_smoke_hooks'][ $hook ] as $callbacks ) {
+ foreach ( $callbacks as $callback ) {
+ $value = call_user_func_array(
+ $callback['callback'],
+ array_slice(
+ array_merge( array( $value ), $arguments ),
+ 0,
+ $callback['accepted_args']
+ )
+ );
+ }
+ }
+
+ return $value;
+ }
+}
+
+if ( ! function_exists( 'get_stylesheet_directory' ) ) {
+ function get_stylesheet_directory(): string {
+ return $GLOBALS['emulsify_asset_manifest_smoke_child'];
+ }
+}
+
+if ( ! function_exists( 'get_template_directory' ) ) {
+ function get_template_directory(): string {
+ return $GLOBALS['emulsify_asset_manifest_smoke_parent'];
+ }
+}
+
+if ( ! function_exists( 'get_stylesheet_directory_uri' ) ) {
+ function get_stylesheet_directory_uri(): string {
+ return 'https://example.test/child';
+ }
+}
+
+if ( ! function_exists( 'get_template_directory_uri' ) ) {
+ function get_template_directory_uri(): string {
+ return 'https://example.test/parent';
+ }
+}
+
+if ( ! function_exists( 'sanitize_key' ) ) {
+ function sanitize_key( string $key ): string {
+ return trim( preg_replace( '/[^a-z0-9_-]+/', '-', strtolower( $key ) ), '-' );
+ }
+}
+
+if ( ! function_exists( 'wp_enqueue_style' ) ) {
+ function wp_enqueue_style( string $handle, string $src, array $deps = array(), $ver = false ): void {
+ $GLOBALS['emulsify_asset_manifest_smoke_styles'][ $handle ] = compact( 'src', 'deps', 'ver' );
+ }
+}
+
+if ( ! function_exists( 'wp_enqueue_script' ) ) {
+ function wp_enqueue_script( string $handle, string $src, array $deps = array(), $ver = false, $args = array() ): void {
+ $GLOBALS['emulsify_asset_manifest_smoke_scripts'][ $handle ] = compact( 'src', 'deps', 'ver', 'args' );
+ }
+}
+
+if ( ! function_exists( 'wp_enqueue_script_module' ) ) {
+ function wp_enqueue_script_module( string $id, string $src, array $deps = array(), $version = false ): void {
+ $GLOBALS['emulsify_asset_manifest_smoke_script_modules'][ $id ] = compact( 'src', 'deps', 'version' );
+ }
+}
+
+if ( ! function_exists( 'wp_add_inline_script' ) ) {
+ function wp_add_inline_script( string $handle, string $data, string $position = 'after' ): bool {
+ $GLOBALS['emulsify_asset_manifest_smoke_inline'][] = compact( 'handle', 'data', 'position' );
+
+ return true;
+ }
+}
+
+if ( ! function_exists( 'wp_json_encode' ) ) {
+ function wp_json_encode( $value ) {
+ return json_encode( $value );
+ }
+}
+
+/**
+ * Fails the smoke script when an assertion is false.
+ *
+ * @param bool $condition Assertion condition.
+ * @param string $message Failure message.
+ * @return void
+ */
+function emulsify_asset_manifest_smoke_assert( bool $condition, string $message ): void {
+ if ( ! $condition ) {
+ throw new RuntimeException( $message );
+ }
+}
+
+/**
+ * Writes a fixture file.
+ *
+ * @param string $path File path.
+ * @param string $content File content.
+ * @return void
+ */
+function emulsify_asset_manifest_smoke_write( string $path, string $content ): void {
+ $directory = dirname( $path );
+
+ if ( ! is_dir( $directory ) ) {
+ mkdir( $directory, 0777, true );
+ }
+
+ file_put_contents( $path, $content );
+}
+
+/**
+ * Writes JSON fixture data.
+ *
+ * @param string $path File path.
+ * @param array $data JSON data.
+ * @return void
+ */
+function emulsify_asset_manifest_smoke_write_json( string $path, array $data ): void {
+ emulsify_asset_manifest_smoke_write( $path, (string) json_encode( $data ) );
+}
+
+/**
+ * Resets smoke globals for a case.
+ *
+ * @param string $child Child theme path.
+ * @param string $parent Parent theme path.
+ * @return void
+ */
+function emulsify_asset_manifest_smoke_reset( string $child, string $parent ): void {
+ $GLOBALS['emulsify_asset_manifest_smoke_child'] = $child;
+ $GLOBALS['emulsify_asset_manifest_smoke_parent'] = $parent;
+ $GLOBALS['emulsify_asset_manifest_smoke_hooks'] = array();
+ $GLOBALS['emulsify_asset_manifest_smoke_styles'] = array();
+ $GLOBALS['emulsify_asset_manifest_smoke_scripts'] = array();
+ $GLOBALS['emulsify_asset_manifest_smoke_script_modules'] = array();
+ $GLOBALS['emulsify_asset_manifest_smoke_inline'] = array();
+}
+
+/**
+ * Recursively removes a path.
+ *
+ * @param string $path Path to remove.
+ * @return void
+ */
+function emulsify_asset_manifest_smoke_remove( string $path ): void {
+ if ( ! file_exists( $path ) ) {
+ return;
+ }
+
+ if ( is_file( $path ) || is_link( $path ) ) {
+ unlink( $path );
+ return;
+ }
+
+ $iterator = new RecursiveIteratorIterator(
+ new RecursiveDirectoryIterator( $path, RecursiveDirectoryIterator::SKIP_DOTS ),
+ RecursiveIteratorIterator::CHILD_FIRST
+ );
+
+ foreach ( $iterator as $item ) {
+ $item->isDir() && ! $item->isLink() ? rmdir( $item->getPathname() ) : unlink( $item->getPathname() );
+ }
+
+ rmdir( $path );
+}
+
+$repo_root = dirname( __DIR__, 2 );
+$work_root = sys_get_temp_dir() . '/emulsify-asset-manifest-' . uniqid( '', true );
+$debug_log = $work_root . '/debug.log';
+
+ini_set( 'log_errors', '1' );
+ini_set( 'error_log', $debug_log );
+
+try {
+ require_once $repo_root . '/includes/Support/FileDiscovery.php';
+ require_once $repo_root . '/includes/Support/AssetRecord.php';
+ require_once $repo_root . '/includes/Support/AssetEnqueuer.php';
+ require_once $repo_root . '/includes/Support/AssetManifest.php';
+ require_once $repo_root . '/includes/Runtime/Assets.php';
+ require_once $repo_root . '/includes/Editor/Enhancements.php';
+
+ $child = $work_root . '/fallback-child';
+ $parent = $work_root . '/fallback-parent';
+ emulsify_asset_manifest_smoke_reset( $child, $parent );
+ emulsify_asset_manifest_smoke_write( $child . '/dist/global/fallback.css', '.fallback{}' );
+ emulsify_asset_manifest_smoke_write( $child . '/dist/components/fallback.js', 'export default true;' );
+
+ ( new Emulsify\Theme\Runtime\Assets() )->styles();
+ ( new Emulsify\Theme\Runtime\Assets() )->frontend_scripts();
+
+ emulsify_asset_manifest_smoke_assert(
+ isset( $GLOBALS['emulsify_asset_manifest_smoke_styles']['emulsify-global-fallback'] ),
+ 'No manifest should fall back to recursive global CSS discovery.'
+ );
+ emulsify_asset_manifest_smoke_assert(
+ isset( $GLOBALS['emulsify_asset_manifest_smoke_script_modules']['emulsify-component-fallback'] ),
+ 'No manifest should fall back to recursive component JS discovery.'
+ );
+
+ $child = $work_root . '/manifest-child';
+ $parent = $work_root . '/manifest-parent';
+ emulsify_asset_manifest_smoke_reset( $child, $parent );
+ emulsify_asset_manifest_smoke_write( $child . '/dist/global/scanner-only.css', '.scanner{}' );
+ emulsify_asset_manifest_smoke_write( $child . '/dist/global/manifest.css', '.manifest{}' );
+ emulsify_asset_manifest_smoke_write( $child . '/dist/global/app.js', 'window.manifestApp = true;' );
+ emulsify_asset_manifest_smoke_write( $child . '/dist/components/card.css', '.card{}' );
+ emulsify_asset_manifest_smoke_write( $child . '/dist/components/card.js', 'export default true;' );
+ emulsify_asset_manifest_smoke_write( $child . '/dist/components/blocks/hero.css', '.hero{}' );
+ emulsify_asset_manifest_smoke_write( $child . '/dist/global/editor/editor.css', '.editor{}' );
+ emulsify_asset_manifest_smoke_write( $child . '/dist/global/editor/editor.js', 'window.editor = true;' );
+ emulsify_asset_manifest_smoke_write_json(
+ $child . '/dist/emulsify-assets.json',
+ array(
+ 'assets' => array(
+ 'global' => array(
+ 'css' => array(
+ array(
+ 'path' => 'global/manifest.css',
+ 'relative' => 'manifest.css',
+ 'version' => 'manifest-css',
+ 'dependencies' => array( 'wp-block-library' ),
+ ),
+ ),
+ 'js' => array(
+ array(
+ 'path' => 'global/app.js',
+ 'relative' => 'app.js',
+ 'hash' => 'manifest-js',
+ 'deps' => array( 'jquery' ),
+ 'module' => false,
+ ),
+ ),
+ ),
+ 'components' => array(
+ 'css' => array(
+ array(
+ 'path' => 'components/card.css',
+ 'relative' => 'card.css',
+ 'version' => 'component-css',
+ ),
+ ),
+ 'js' => array(
+ array(
+ 'path' => 'components/card.js',
+ 'relative' => 'card.js',
+ 'version' => 'component-js',
+ 'module' => true,
+ ),
+ ),
+ ),
+ 'blocks' => array(
+ 'emulsify/hero' => array(
+ 'css' => array(
+ array(
+ 'path' => 'components/blocks/hero.css',
+ 'relative' => 'blocks/hero.css',
+ 'version' => 'block-css',
+ ),
+ ),
+ ),
+ ),
+ 'editor' => array(
+ 'css' => array(
+ array(
+ 'path' => 'global/editor/editor.css',
+ 'version' => 'editor-css',
+ ),
+ ),
+ 'js' => array(
+ array(
+ 'path' => 'global/editor/editor.js',
+ 'version' => 'editor-js',
+ 'dependencies' => array( 'wp-edit-post' ),
+ ),
+ ),
+ ),
+ ),
+ )
+ );
+
+ ( new Emulsify\Theme\Runtime\Assets() )->styles();
+ ( new Emulsify\Theme\Runtime\Assets() )->frontend_scripts();
+ ( new Emulsify\Theme\Editor\Enhancements() )->editor_assets();
+
+ emulsify_asset_manifest_smoke_assert(
+ isset( $GLOBALS['emulsify_asset_manifest_smoke_styles']['emulsify-global-manifest'] )
+ && 'manifest-css' === $GLOBALS['emulsify_asset_manifest_smoke_styles']['emulsify-global-manifest']['ver'],
+ 'Valid manifest should enqueue global CSS with explicit version metadata.'
+ );
+ emulsify_asset_manifest_smoke_assert(
+ in_array( 'wp-block-library', $GLOBALS['emulsify_asset_manifest_smoke_styles']['emulsify-global-manifest']['deps'], true ),
+ 'Valid manifest should preserve style dependencies.'
+ );
+ emulsify_asset_manifest_smoke_assert(
+ ! isset( $GLOBALS['emulsify_asset_manifest_smoke_styles']['emulsify-global-scanner-only'] ),
+ 'Declared manifest scopes should avoid recursive scanner-only assets.'
+ );
+ emulsify_asset_manifest_smoke_assert(
+ isset( $GLOBALS['emulsify_asset_manifest_smoke_styles']['emulsify-component-card'] )
+ && ! isset( $GLOBALS['emulsify_asset_manifest_smoke_styles']['emulsify-component-blocks-hero'] ),
+ 'Valid manifest should enqueue broad component CSS without auto-loading block-specific CSS records.'
+ );
+ emulsify_asset_manifest_smoke_assert(
+ isset( $GLOBALS['emulsify_asset_manifest_smoke_scripts']['emulsify-global-app'] )
+ && 'manifest-js' === $GLOBALS['emulsify_asset_manifest_smoke_scripts']['emulsify-global-app']['ver']
+ && in_array( 'jquery', $GLOBALS['emulsify_asset_manifest_smoke_scripts']['emulsify-global-app']['deps'], true ),
+ 'Manifest global JS should support classic scripts, hashes, and dependencies.'
+ );
+ emulsify_asset_manifest_smoke_assert(
+ isset( $GLOBALS['emulsify_asset_manifest_smoke_script_modules']['emulsify-component-card'] ),
+ 'Manifest component JS should support script modules.'
+ );
+ emulsify_asset_manifest_smoke_assert(
+ isset( $GLOBALS['emulsify_asset_manifest_smoke_styles']['emulsify-editor-global-editor-editor'] )
+ && isset( $GLOBALS['emulsify_asset_manifest_smoke_scripts']['emulsify-editor-global-editor-editor'] )
+ && in_array( 'wp-edit-post', $GLOBALS['emulsify_asset_manifest_smoke_scripts']['emulsify-editor-global-editor-editor']['deps'], true ),
+ 'Manifest editor assets should enqueue with editor dependencies.'
+ );
+
+ $child = $work_root . '/invalid-child';
+ $parent = $work_root . '/invalid-parent';
+ emulsify_asset_manifest_smoke_reset( $child, $parent );
+ emulsify_asset_manifest_smoke_write( $child . '/dist/emulsify-assets.json', '{invalid' );
+ emulsify_asset_manifest_smoke_write( $child . '/dist/global/fallback.css', '.fallback{}' );
+ emulsify_asset_manifest_smoke_write( $parent . '/dist/global/parent.css', '.parent{}' );
+ emulsify_asset_manifest_smoke_write_json(
+ $parent . '/dist/emulsify-assets.json',
+ array(
+ 'assets' => array(
+ 'global' => array(
+ 'css' => array(
+ array( 'path' => 'global/parent.css' ),
+ array( 'path' => 'global/missing.css' ),
+ ),
+ ),
+ ),
+ )
+ );
+
+ ( new Emulsify\Theme\Runtime\Assets() )->styles();
+
+ emulsify_asset_manifest_smoke_assert(
+ isset( $GLOBALS['emulsify_asset_manifest_smoke_styles']['emulsify-global-global-parent'] )
+ && ! isset( $GLOBALS['emulsify_asset_manifest_smoke_styles']['emulsify-global-fallback'] ),
+ 'Invalid manifest should fall back to the valid parent manifest before recursive discovery.'
+ );
+
+ $debug_contents = is_readable( $debug_log ) ? file_get_contents( $debug_log ) : '';
+
+ emulsify_asset_manifest_smoke_assert(
+ is_string( $debug_contents )
+ && false !== strpos( $debug_contents, '[Emulsify] Asset manifest contains invalid JSON: ' . $child . '/dist/emulsify-assets.json' ),
+ 'WP_DEBUG should log malformed asset manifest candidates.'
+ );
+ emulsify_asset_manifest_smoke_assert(
+ is_string( $debug_contents )
+ && false !== strpos( $debug_contents, '[Emulsify] Asset not readable: ' . $parent . '/dist/global/missing.css' ),
+ 'WP_DEBUG should log missing manifest asset files.'
+ );
+
+ $child = $work_root . '/priority-child';
+ $parent = $work_root . '/priority-parent';
+ emulsify_asset_manifest_smoke_reset( $child, $parent );
+ emulsify_asset_manifest_smoke_write( $child . '/dist/global/child.css', '.child{}' );
+ emulsify_asset_manifest_smoke_write( $parent . '/dist/global/parent.css', '.parent{}' );
+ emulsify_asset_manifest_smoke_write_json(
+ $child . '/dist/emulsify-assets.json',
+ array(
+ 'assets' => array(
+ 'global' => array(
+ 'css' => array( array( 'path' => 'global/child.css' ) ),
+ ),
+ ),
+ )
+ );
+ emulsify_asset_manifest_smoke_write_json(
+ $parent . '/dist/emulsify-assets.json',
+ array(
+ 'assets' => array(
+ 'global' => array(
+ 'css' => array( array( 'path' => 'global/parent.css' ) ),
+ ),
+ ),
+ )
+ );
+
+ ( new Emulsify\Theme\Runtime\Assets() )->styles();
+
+ emulsify_asset_manifest_smoke_assert(
+ isset( $GLOBALS['emulsify_asset_manifest_smoke_styles']['emulsify-global-global-child'] )
+ && ! isset( $GLOBALS['emulsify_asset_manifest_smoke_styles']['emulsify-global-global-parent'] ),
+ 'Child manifest should take priority over parent manifest.'
+ );
+
+ $child = $work_root . '/filter-child';
+ $parent = $work_root . '/filter-parent';
+ emulsify_asset_manifest_smoke_reset( $child, $parent );
+ emulsify_asset_manifest_smoke_write( $child . '/dist/alt/filter.css', '.filter{}' );
+ emulsify_asset_manifest_smoke_write_json(
+ $child . '/dist/custom-assets.json',
+ array(
+ 'assets' => array(
+ 'global' => array(
+ 'css' => array( array( 'path' => 'alt/filter.css' ) ),
+ ),
+ ),
+ )
+ );
+
+ add_filter(
+ 'emulsify_theme_asset_manifest_path',
+ static function ( string $path ): string {
+ unset( $path );
+
+ return 'dist/custom-assets.json';
+ }
+ );
+ add_filter(
+ 'emulsify_theme_asset_manifest_data',
+ static function ( array $data ): array {
+ $data['assets']['global']['css'][0]['version'] = 'filtered-manifest';
+
+ return $data;
+ }
+ );
+ add_filter(
+ 'emulsify_theme_asset_files',
+ static function ( array $assets, string $directory ): array {
+ if ( 'dist/global' === $directory && isset( $assets[0]['manifest_path'] ) ) {
+ $assets[0]['version'] = 'filtered-final';
+ }
+
+ return $assets;
+ },
+ 10,
+ 2
+ );
+
+ ( new Emulsify\Theme\Runtime\Assets() )->styles();
+
+ emulsify_asset_manifest_smoke_assert(
+ isset( $GLOBALS['emulsify_asset_manifest_smoke_styles']['emulsify-global-alt-filter'] )
+ && 'filtered-final' === $GLOBALS['emulsify_asset_manifest_smoke_styles']['emulsify-global-alt-filter']['ver'],
+ 'Manifest path, parsed data, and final asset record filters should apply before enqueue.'
+ );
+
+ echo "Asset manifest smoke checks passed.\n";
+} catch ( Throwable $throwable ) {
+ fwrite( STDERR, $throwable->getMessage() . "\n" );
+ exit( 1 );
+} finally {
+ emulsify_asset_manifest_smoke_remove( $work_root );
+}
diff --git a/.github/scripts/attribute-helper-smoke.php b/.github/scripts/attribute-helper-smoke.php
new file mode 100644
index 0000000..b8a3170
--- /dev/null
+++ b/.github/scripts/attribute-helper-smoke.php
@@ -0,0 +1,262 @@
+functions( array() ) as $name => $definition ) {
+ $options = array(
+ 'is_safe' => $definition['is_safe'] ?? array(),
+ 'needs_context' => $definition['needs_context'] ?? false,
+ );
+
+ $environment->addFunction( new \Twig\TwigFunction( $name, $definition['callable'], $options ) );
+ }
+}
+
+/**
+ * Registers minimal WordPress/Timber shims needed to parse parent templates.
+ *
+ * @param \Twig\Environment $environment Twig environment.
+ * @return void
+ */
+function emulsify_register_smoke_wordpress_twig_shims( \Twig\Environment $environment ): void {
+ $environment->addFunction(
+ new \Twig\TwigFunction(
+ 'function',
+ static function ( string $name, ...$arguments ) {
+ if ( '__' === $name || 'esc_attr__' === $name ) {
+ return $arguments[0] ?? '';
+ }
+
+ return '';
+ },
+ array( 'is_safe' => array( 'html' ) )
+ )
+ );
+ $environment->addFunction( new \Twig\TwigFunction( 'action', static function (): string { return ''; } ) );
+ $environment->addFilter( new \Twig\TwigFilter( 'wpautop', static function ( $value ) { return $value; }, array( 'is_safe' => array( 'html' ) ) ) );
+ $environment->addFilter( new \Twig\TwigFilter( 'resize', static function ( $value ) { return $value; } ) );
+}
+
+/**
+ * Asserts parent templates route class output through add_attributes().
+ *
+ * @param string $templates_dir Parent template directory.
+ * @return void
+ */
+function emulsify_attribute_helper_assert_parent_templates_use_helpers( string $templates_dir ): void {
+ $iterator = new RecursiveIteratorIterator(
+ new RecursiveDirectoryIterator( $templates_dir, RecursiveDirectoryIterator::SKIP_DOTS )
+ );
+
+ foreach ( $iterator as $file ) {
+ if ( 'twig' !== $file->getExtension() ) {
+ continue;
+ }
+
+ $contents = file_get_contents( $file->getPathname() );
+
+ if ( is_string( $contents ) && preg_match( '/\sclass=(["\'])/', $contents ) ) {
+ fwrite( STDERR, sprintf( "Parent template should use bem() or add_attributes() for classes: %s\n", $file->getPathname() ) );
+ exit( 1 );
+ }
+
+ if ( is_string( $contents ) && preg_match( '/{{\s*bem\s*\(/', $contents ) ) {
+ fwrite( STDERR, sprintf( "Parent template should pass bem() through add_attributes(): %s\n", $file->getPathname() ) );
+ exit( 1 );
+ }
+ }
+}
+
+/**
+ * Parses parent templates with the attribute helper functions registered.
+ *
+ * @param Twig $helpers Emulsify helper integration.
+ * @param string $templates_dir Parent template directory.
+ * @return int Parsed template count.
+ */
+function emulsify_attribute_helper_parse_parent_templates( Twig $helpers, string $templates_dir ): int {
+ $loader = new \Twig\Loader\FilesystemLoader();
+ $loader->addPath( $templates_dir, 'templates' );
+ $loader->addPath( $templates_dir, 'emulsify-tpl' );
+
+ $environment = new \Twig\Environment(
+ $loader,
+ array(
+ 'autoescape' => false,
+ 'cache' => false,
+ )
+ );
+
+ emulsify_register_smoke_twig_functions( $environment, $helpers );
+ emulsify_register_smoke_wordpress_twig_shims( $environment );
+
+ $checked = 0;
+ $iterator = new RecursiveIteratorIterator(
+ new RecursiveDirectoryIterator( $templates_dir, RecursiveDirectoryIterator::SKIP_DOTS )
+ );
+
+ foreach ( $iterator as $file ) {
+ if ( 'twig' !== $file->getExtension() ) {
+ continue;
+ }
+
+ $relative = str_replace( rtrim( $templates_dir, '/\\' ) . '/', '', $file->getPathname() );
+ $template = '@templates/' . str_replace( '\\', '/', $relative );
+
+ $environment->parse( $environment->tokenize( $environment->getLoader()->getSourceContext( $template ) ) );
+ ++$checked;
+ }
+
+ return $checked;
+}
+
+$helpers = new Twig();
+
+emulsify_attribute_helper_assert_same(
+ 'bem positional syntax',
+ 'class="example-card example-card--featured"',
+ (string) $helpers->bem( 'example-card', array( 'featured' ) )
+);
+
+emulsify_attribute_helper_assert_same(
+ 'bem object syntax',
+ 'data-state="open" disabled class="card__title card__title--featured u-mb-0"',
+ (string) $helpers->bem(
+ array(
+ 'block' => 'card',
+ 'element' => 'title',
+ 'modifiers' => array( 'featured' ),
+ 'extra' => array( 'u-mb-0' ),
+ 'attributes' => array(
+ 'data-state' => 'open',
+ 'disabled' => true,
+ 'onclick bad' => 'ignored',
+ ),
+ )
+ )
+);
+
+emulsify_attribute_helper_assert_same(
+ 'add_attributes array syntax',
+ 'class="foo" data-label="A&B" hidden',
+ (string) $helpers->add_attributes(
+ array(
+ 'class' => array( 'foo' ),
+ 'data-label' => 'A&B',
+ 'hidden' => true,
+ )
+ )
+);
+
+$bag = new AttributeBag(
+ array(
+ 'class' => array( 'foo', 'foo' ),
+ 'id' => 'example',
+ )
+);
+$bag->merge(
+ array(
+ 'class' => array( 'bar' ),
+ 'id' => 'updated',
+ )
+);
+
+emulsify_attribute_helper_assert_same(
+ 'AttributeBag merge syntax',
+ 'class="foo bar" id="updated"',
+ (string) $helpers->add_attributes( $bag )
+);
+
+emulsify_attribute_helper_assert_same(
+ 'context-aware add_attributes syntax',
+ 'class="context foo"',
+ (string) $helpers->add_attributes(
+ array( 'attributes' => new AttributeBag( array( 'class' => array( 'context' ) ) ) ),
+ array( 'class' => array( 'foo' ) )
+ )
+);
+
+emulsify_attribute_helper_assert_same(
+ 'context-aware bem syntax',
+ 'class="example-card example-card--featured context"',
+ (string) $helpers->bem(
+ array( 'attributes' => new AttributeBag( array( 'class' => array( 'context' ) ) ) ),
+ 'example-card',
+ array( 'featured' )
+ )
+);
+
+$autoload = __DIR__ . '/../../vendor/autoload.php';
+
+if ( is_readable( $autoload ) ) {
+ require_once $autoload;
+}
+
+$templates_dir = __DIR__ . '/../../templates';
+emulsify_attribute_helper_assert_parent_templates_use_helpers( $templates_dir );
+
+if (
+ class_exists( \Twig\Environment::class )
+ && class_exists( \Twig\Loader\ArrayLoader::class )
+ && class_exists( \Twig\Loader\FilesystemLoader::class )
+ && class_exists( \Twig\TwigFunction::class )
+) {
+ $environment = new \Twig\Environment(
+ new \Twig\Loader\ArrayLoader(
+ array(
+ 'fixture' => '{{ bem("example-card", ["featured"]) }}|{{ add_attributes({ class: ["foo"] }) }}',
+ )
+ ),
+ array( 'autoescape' => false )
+ );
+
+ emulsify_register_smoke_twig_functions( $environment, $helpers );
+
+ emulsify_attribute_helper_assert_same(
+ 'Twig fixture render',
+ 'class="example-card example-card--featured"|class="foo"',
+ $environment->render( 'fixture' )
+ );
+
+ $parsed = emulsify_attribute_helper_parse_parent_templates( $helpers, $templates_dir );
+
+ echo sprintf( "Attribute helper smoke checks passed with Twig fixture rendering and %d parent template parse checks.\n", $parsed );
+ exit( 0 );
+}
+
+echo "Attribute helper smoke checks passed. Twig fixture rendering skipped because Twig is not installed.\n";
diff --git a/.github/scripts/block-scoped-assets-smoke.php b/.github/scripts/block-scoped-assets-smoke.php
new file mode 100644
index 0000000..f79c12a
--- /dev/null
+++ b/.github/scripts/block-scoped-assets-smoke.php
@@ -0,0 +1,515 @@
+ $accepted_args,
+ 'callback' => $callback,
+ );
+
+ ksort( $GLOBALS['emulsify_block_assets_smoke_hooks'][ $hook ] );
+
+ return true;
+ }
+}
+
+if ( ! function_exists( 'apply_filters' ) ) {
+ function apply_filters( string $hook, $value, ...$arguments ) {
+ if ( empty( $GLOBALS['emulsify_block_assets_smoke_hooks'][ $hook ] ) ) {
+ return $value;
+ }
+
+ foreach ( $GLOBALS['emulsify_block_assets_smoke_hooks'][ $hook ] as $callbacks ) {
+ foreach ( $callbacks as $callback ) {
+ $value = call_user_func_array(
+ $callback['callback'],
+ array_slice(
+ array_merge( array( $value ), $arguments ),
+ 0,
+ $callback['accepted_args']
+ )
+ );
+ }
+ }
+
+ return $value;
+ }
+}
+
+if ( ! function_exists( 'get_stylesheet_directory' ) ) {
+ function get_stylesheet_directory(): string {
+ return $GLOBALS['emulsify_block_assets_smoke_child'];
+ }
+}
+
+if ( ! function_exists( 'get_template_directory' ) ) {
+ function get_template_directory(): string {
+ return $GLOBALS['emulsify_block_assets_smoke_parent'];
+ }
+}
+
+if ( ! function_exists( 'get_stylesheet_directory_uri' ) ) {
+ function get_stylesheet_directory_uri(): string {
+ return 'https://example.test/child';
+ }
+}
+
+if ( ! function_exists( 'get_template_directory_uri' ) ) {
+ function get_template_directory_uri(): string {
+ return 'https://example.test/parent';
+ }
+}
+
+if ( ! function_exists( 'sanitize_title' ) ) {
+ function sanitize_title( string $title ): string {
+ return trim( preg_replace( '/[^a-z0-9]+/', '-', strtolower( $title ) ), '-' );
+ }
+}
+
+if ( ! function_exists( 'sanitize_key' ) ) {
+ function sanitize_key( string $key ): string {
+ return trim( preg_replace( '/[^a-z0-9_-]+/', '-', strtolower( $key ) ), '-' );
+ }
+}
+
+if ( ! function_exists( 'is_admin' ) ) {
+ function is_admin(): bool {
+ return ! empty( $GLOBALS['emulsify_block_assets_smoke_is_admin'] );
+ }
+}
+
+if ( ! function_exists( 'acf_register_block_type' ) ) {
+ function acf_register_block_type( array $args ) {
+ $GLOBALS['emulsify_block_assets_smoke_acf_blocks'][ $args['name'] ] = $args;
+
+ return $args;
+ }
+}
+
+if ( ! function_exists( 'acf_get_block_type' ) ) {
+ function acf_get_block_type( string $name ) {
+ return null;
+ }
+}
+
+if ( ! function_exists( 'register_block_type' ) ) {
+ function register_block_type( string $path ) {
+ $GLOBALS['emulsify_block_assets_smoke_native_blocks'][] = $path;
+
+ return $path;
+ }
+}
+
+if ( ! function_exists( 'wp_enqueue_style' ) ) {
+ function wp_enqueue_style( string $handle, string $src, array $deps = array(), $ver = false ): void {
+ $GLOBALS['emulsify_block_assets_smoke_styles'][ $handle ] = compact( 'src', 'deps', 'ver' );
+ }
+}
+
+if ( ! function_exists( 'wp_enqueue_script' ) ) {
+ function wp_enqueue_script( string $handle, string $src, array $deps = array(), $ver = false, $args = array() ): void {
+ $GLOBALS['emulsify_block_assets_smoke_scripts'][ $handle ] = compact( 'src', 'deps', 'ver', 'args' );
+ }
+}
+
+if ( ! function_exists( 'wp_enqueue_script_module' ) ) {
+ function wp_enqueue_script_module( string $id, string $src, array $deps = array(), $version = false ): void {
+ $GLOBALS['emulsify_block_assets_smoke_script_modules'][ $id ] = compact( 'src', 'deps', 'version' );
+ }
+}
+
+/**
+ * Fails the smoke script when an assertion is false.
+ *
+ * @param bool $condition Assertion condition.
+ * @param string $message Failure message.
+ * @return void
+ */
+function emulsify_block_assets_smoke_assert( bool $condition, string $message ): void {
+ if ( ! $condition ) {
+ throw new RuntimeException( $message );
+ }
+}
+
+/**
+ * Writes a fixture file.
+ *
+ * @param string $path File path.
+ * @param string $content File content.
+ * @return void
+ */
+function emulsify_block_assets_smoke_write( string $path, string $content ): void {
+ $directory = dirname( $path );
+
+ if ( ! is_dir( $directory ) ) {
+ mkdir( $directory, 0777, true );
+ }
+
+ file_put_contents( $path, $content );
+}
+
+/**
+ * Writes JSON fixture data.
+ *
+ * @param string $path File path.
+ * @param array $data JSON data.
+ * @return void
+ */
+function emulsify_block_assets_smoke_write_json( string $path, array $data ): void {
+ emulsify_block_assets_smoke_write( $path, (string) json_encode( $data ) );
+}
+
+/**
+ * Resets captured enqueue calls.
+ *
+ * @return void
+ */
+function emulsify_block_assets_smoke_reset_enqueues(): void {
+ $GLOBALS['emulsify_block_assets_smoke_styles'] = array();
+ $GLOBALS['emulsify_block_assets_smoke_scripts'] = array();
+ $GLOBALS['emulsify_block_assets_smoke_script_modules'] = array();
+}
+
+/**
+ * Recursively removes a path.
+ *
+ * @param string $path Path to remove.
+ * @return void
+ */
+function emulsify_block_assets_smoke_remove( string $path ): void {
+ if ( ! file_exists( $path ) ) {
+ return;
+ }
+
+ if ( is_file( $path ) || is_link( $path ) ) {
+ unlink( $path );
+ return;
+ }
+
+ $iterator = new RecursiveIteratorIterator(
+ new RecursiveDirectoryIterator( $path, RecursiveDirectoryIterator::SKIP_DOTS ),
+ RecursiveIteratorIterator::CHILD_FIRST
+ );
+
+ foreach ( $iterator as $item ) {
+ $item->isDir() && ! $item->isLink() ? rmdir( $item->getPathname() ) : unlink( $item->getPathname() );
+ }
+
+ rmdir( $path );
+}
+
+$repo_root = dirname( __DIR__, 2 );
+$work_root = sys_get_temp_dir() . '/emulsify-block-assets-' . uniqid( '', true );
+$child = $work_root . '/child-theme';
+$parent = $work_root . '/parent-theme';
+$debug_log = $work_root . '/debug.log';
+
+ini_set( 'log_errors', '1' );
+ini_set( 'error_log', $debug_log );
+
+$GLOBALS['emulsify_block_assets_smoke_child'] = $child;
+$GLOBALS['emulsify_block_assets_smoke_parent'] = $parent;
+
+try {
+ emulsify_block_assets_smoke_write_json(
+ $child . '/dist/components/card/card.component.json',
+ array(
+ 'title' => 'Card',
+ 'assets' => array(
+ 'frontend' => array(
+ 'css' => array(
+ array(
+ 'path' => 'card.css',
+ 'version' => 'metadata-css',
+ ),
+ array(
+ 'path' => 'missing.css',
+ ),
+ ),
+ 'js' => array(
+ array(
+ 'path' => 'card.js',
+ 'version' => 'metadata-js',
+ 'dependencies' => array( 'jquery' ),
+ 'module' => false,
+ ),
+ ),
+ ),
+ 'editor' => array(
+ 'css' => array(
+ array(
+ 'path' => 'editor.css',
+ 'version' => 'metadata-editor-css',
+ ),
+ ),
+ 'js' => array(
+ array(
+ 'path' => 'editor.js',
+ 'version' => 'metadata-editor-js',
+ 'dependencies' => array( 'wp-blocks' ),
+ ),
+ ),
+ ),
+ ),
+ )
+ );
+ emulsify_block_assets_smoke_write( $child . '/dist/components/card/card.twig', 'Card' );
+ emulsify_block_assets_smoke_write( $child . '/dist/components/card/card.css', '.card{}' );
+ emulsify_block_assets_smoke_write( $child . '/dist/components/card/card.js', 'window.card = true;' );
+ emulsify_block_assets_smoke_write( $child . '/dist/components/card/editor.css', '.editor{}' );
+ emulsify_block_assets_smoke_write( $child . '/dist/components/card/editor.js', 'window.editorCard = true;' );
+
+ emulsify_block_assets_smoke_write_json(
+ $child . '/dist/components/hero/hero.component.json',
+ array(
+ 'title' => 'Hero',
+ 'assets' => array(
+ 'frontend' => array(
+ 'css' => array(
+ array(
+ 'path' => 'metadata.css',
+ 'version' => 'metadata-hero-css',
+ ),
+ ),
+ ),
+ ),
+ )
+ );
+ emulsify_block_assets_smoke_write( $child . '/dist/components/hero/hero.twig', '' );
+ emulsify_block_assets_smoke_write( $child . '/dist/components/hero/metadata.css', '.metadata-hero{}' );
+ emulsify_block_assets_smoke_write( $child . '/dist/components/hero/manifest.css', '.manifest-hero{}' );
+
+ emulsify_block_assets_smoke_write_json(
+ $child . '/dist/components/plain/plain.component.json',
+ array(
+ 'title' => 'Plain',
+ )
+ );
+ emulsify_block_assets_smoke_write( $child . '/dist/components/plain/plain.twig', '
Plain
' );
+
+ emulsify_block_assets_smoke_write_json(
+ $child . '/dist/components/native/block.json',
+ array(
+ 'name' => 'emulsify/native',
+ 'style' => 'file:./style.css',
+ 'viewScript' => 'file:./view.js',
+ 'editorScript' => 'file:./editor.js',
+ )
+ );
+ emulsify_block_assets_smoke_write( $child . '/dist/components/native/style.css', '.native{}' );
+ emulsify_block_assets_smoke_write( $child . '/dist/components/native/view.js', 'window.nativeView = true;' );
+ emulsify_block_assets_smoke_write( $child . '/dist/components/native/editor.js', 'window.nativeEditor = true;' );
+
+ emulsify_block_assets_smoke_write_json(
+ $child . '/dist/emulsify-assets.json',
+ array(
+ 'assets' => array(
+ 'blocks' => array(
+ 'acf/emulsify-hero' => array(
+ 'frontend' => array(
+ 'css' => array(
+ array(
+ 'path' => 'components/hero/manifest.css',
+ 'relative' => 'hero-manifest.css',
+ 'version' => 'manifest-hero-css',
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ )
+ );
+
+ require_once $repo_root . '/includes/Support/FileDiscovery.php';
+ require_once $repo_root . '/includes/Support/AssetRecord.php';
+ require_once $repo_root . '/includes/Support/AssetEnqueuer.php';
+ require_once $repo_root . '/includes/Support/Diagnostics.php';
+ require_once $repo_root . '/includes/Support/AssetManifest.php';
+ require_once $repo_root . '/includes/Runtime/Assets.php';
+ require_once $repo_root . '/includes/Blocks/ComponentLocator.php';
+ require_once $repo_root . '/includes/Blocks/AcfBlocks.php';
+ require_once $repo_root . '/includes/Blocks/NativeBlocks.php';
+
+ add_filter(
+ 'emulsify_theme_acf_block_asset_records',
+ static function ( array $records, array $component ): array {
+ if ( 'card' === $component['relative'] && isset( $records['frontend']['css'][0] ) ) {
+ $records['frontend']['css'][0]['version'] = 'filtered-card-css';
+ }
+
+ return $records;
+ },
+ 10,
+ 2
+ );
+
+ $acf_blocks = new Emulsify\Theme\Blocks\AcfBlocks( new Emulsify\Theme\Blocks\ComponentLocator() );
+ $acf_blocks->register_blocks();
+
+ $debug_contents = is_readable( $debug_log ) ? file_get_contents( $debug_log ) : '';
+
+ emulsify_block_assets_smoke_assert(
+ is_string( $debug_contents )
+ && false !== strpos( $debug_contents, '[Emulsify] Asset not readable: ' . $child . '/dist/components/card/missing.css' ),
+ 'WP_DEBUG should log missing ACF component metadata assets.'
+ );
+
+ emulsify_block_assets_smoke_assert(
+ isset( $GLOBALS['emulsify_block_assets_smoke_acf_blocks']['emulsify-card']['enqueue_assets'] )
+ && is_callable( $GLOBALS['emulsify_block_assets_smoke_acf_blocks']['emulsify-card']['enqueue_assets'] ),
+ 'ACF/Twig blocks with component metadata assets should register an enqueue_assets callback.'
+ );
+ emulsify_block_assets_smoke_assert(
+ isset( $GLOBALS['emulsify_block_assets_smoke_acf_blocks']['emulsify-hero']['enqueue_assets'] )
+ && is_callable( $GLOBALS['emulsify_block_assets_smoke_acf_blocks']['emulsify-hero']['enqueue_assets'] ),
+ 'ACF/Twig blocks with manifest assets should register an enqueue_assets callback.'
+ );
+ emulsify_block_assets_smoke_assert(
+ empty( $GLOBALS['emulsify_block_assets_smoke_acf_blocks']['emulsify-plain']['enqueue_assets'] ),
+ 'ACF/Twig blocks without manifest or metadata assets should keep global component loading as the fallback.'
+ );
+
+ $GLOBALS['emulsify_block_assets_smoke_is_admin'] = false;
+ emulsify_block_assets_smoke_reset_enqueues();
+ $GLOBALS['emulsify_block_assets_smoke_acf_blocks']['emulsify-card']['enqueue_assets']();
+
+ emulsify_block_assets_smoke_assert(
+ isset( $GLOBALS['emulsify_block_assets_smoke_styles']['emulsify-acf-frontend-emulsify-card-card'] )
+ && 'filtered-card-css' === $GLOBALS['emulsify_block_assets_smoke_styles']['emulsify-acf-frontend-emulsify-card-card']['ver'],
+ 'ACF/Twig metadata frontend CSS should enqueue only when the block callback runs and should be filterable.'
+ );
+ emulsify_block_assets_smoke_assert(
+ isset( $GLOBALS['emulsify_block_assets_smoke_scripts']['emulsify-acf-frontend-emulsify-card-card'] )
+ && in_array( 'jquery', $GLOBALS['emulsify_block_assets_smoke_scripts']['emulsify-acf-frontend-emulsify-card-card']['deps'], true ),
+ 'ACF/Twig metadata frontend JS should enqueue with dependencies.'
+ );
+ emulsify_block_assets_smoke_assert(
+ empty( $GLOBALS['emulsify_block_assets_smoke_styles']['emulsify-acf-editor-emulsify-card-editor'] ),
+ 'ACF/Twig editor assets should not enqueue on frontend block renders.'
+ );
+
+ $GLOBALS['emulsify_block_assets_smoke_is_admin'] = true;
+ emulsify_block_assets_smoke_reset_enqueues();
+ $GLOBALS['emulsify_block_assets_smoke_acf_blocks']['emulsify-card']['enqueue_assets']();
+
+ emulsify_block_assets_smoke_assert(
+ isset( $GLOBALS['emulsify_block_assets_smoke_styles']['emulsify-acf-editor-emulsify-card-editor'] )
+ && isset( $GLOBALS['emulsify_block_assets_smoke_script_modules']['emulsify-acf-editor-emulsify-card-editor'] )
+ && in_array( 'wp-blocks', $GLOBALS['emulsify_block_assets_smoke_script_modules']['emulsify-acf-editor-emulsify-card-editor']['deps'], true ),
+ 'ACF/Twig metadata editor assets should enqueue in admin/editor contexts.'
+ );
+
+ $GLOBALS['emulsify_block_assets_smoke_is_admin'] = false;
+ emulsify_block_assets_smoke_reset_enqueues();
+ $GLOBALS['emulsify_block_assets_smoke_acf_blocks']['emulsify-hero']['enqueue_assets']();
+
+ emulsify_block_assets_smoke_assert(
+ isset( $GLOBALS['emulsify_block_assets_smoke_styles']['emulsify-acf-frontend-emulsify-hero-hero-manifest'] )
+ && 'manifest-hero-css' === $GLOBALS['emulsify_block_assets_smoke_styles']['emulsify-acf-frontend-emulsify-hero-hero-manifest']['ver']
+ && ! isset( $GLOBALS['emulsify_block_assets_smoke_styles']['emulsify-acf-frontend-emulsify-hero-metadata'] ),
+ 'Manifest block assets should take priority over component metadata assets.'
+ );
+
+ emulsify_block_assets_smoke_reset_enqueues();
+ ( new Emulsify\Theme\Blocks\NativeBlocks( new Emulsify\Theme\Blocks\ComponentLocator() ) )->register_blocks();
+
+ emulsify_block_assets_smoke_assert(
+ in_array( $child . '/dist/components/native', $GLOBALS['emulsify_block_assets_smoke_native_blocks'], true ),
+ 'Native blocks with block.json should be registered through register_block_type().'
+ );
+ emulsify_block_assets_smoke_assert(
+ empty( $GLOBALS['emulsify_block_assets_smoke_styles'] )
+ && empty( $GLOBALS['emulsify_block_assets_smoke_scripts'] )
+ && empty( $GLOBALS['emulsify_block_assets_smoke_script_modules'] ),
+ 'Native block.json asset fields should be left for WordPress to enqueue.'
+ );
+
+ $GLOBALS['emulsify_block_assets_smoke_child'] = $work_root . '/scanner-child';
+ $GLOBALS['emulsify_block_assets_smoke_parent'] = $work_root . '/scanner-parent';
+ emulsify_block_assets_smoke_reset_enqueues();
+ emulsify_block_assets_smoke_write_json(
+ $GLOBALS['emulsify_block_assets_smoke_child'] . '/dist/components/card/card.component.json',
+ array(
+ 'title' => 'Scanner Card',
+ 'assets' => array(
+ 'frontend' => array(
+ 'css' => array( array( 'path' => 'card.css' ) ),
+ ),
+ ),
+ )
+ );
+ emulsify_block_assets_smoke_write( $GLOBALS['emulsify_block_assets_smoke_child'] . '/dist/components/card/card.css', '.card{}' );
+ emulsify_block_assets_smoke_write( $GLOBALS['emulsify_block_assets_smoke_child'] . '/dist/components/plain/plain.css', '.plain{}' );
+
+ ( new Emulsify\Theme\Runtime\Assets() )->styles();
+
+ emulsify_block_assets_smoke_assert(
+ ! isset( $GLOBALS['emulsify_block_assets_smoke_styles']['emulsify-component-card-card'] ),
+ 'Global component scanning should skip assets declared as block-scoped component metadata.'
+ );
+ emulsify_block_assets_smoke_assert(
+ isset( $GLOBALS['emulsify_block_assets_smoke_styles']['emulsify-component-plain-plain'] ),
+ 'Global component scanning should remain the fallback when no scoped metadata exists.'
+ );
+
+ $GLOBALS['emulsify_block_assets_smoke_child'] = $work_root . '/manifest-scanner-child';
+ $GLOBALS['emulsify_block_assets_smoke_parent'] = $work_root . '/manifest-scanner-parent';
+ emulsify_block_assets_smoke_reset_enqueues();
+ emulsify_block_assets_smoke_write_json(
+ $GLOBALS['emulsify_block_assets_smoke_child'] . '/dist/emulsify-assets.json',
+ array(
+ 'assets' => array(
+ 'blocks' => array(
+ 'acf/emulsify-card' => array(
+ 'frontend' => array(
+ 'css' => array( array( 'path' => 'components/card/card.css' ) ),
+ ),
+ ),
+ ),
+ ),
+ )
+ );
+ emulsify_block_assets_smoke_write( $GLOBALS['emulsify_block_assets_smoke_child'] . '/dist/components/card/card.css', '.card{}' );
+ emulsify_block_assets_smoke_write( $GLOBALS['emulsify_block_assets_smoke_child'] . '/dist/components/plain/plain.css', '.plain{}' );
+
+ ( new Emulsify\Theme\Runtime\Assets() )->styles();
+
+ emulsify_block_assets_smoke_assert(
+ ! isset( $GLOBALS['emulsify_block_assets_smoke_styles']['emulsify-component-card-card'] ),
+ 'Block-scoped manifest assets should not be loaded by the global component scanner.'
+ );
+ emulsify_block_assets_smoke_assert(
+ isset( $GLOBALS['emulsify_block_assets_smoke_styles']['emulsify-component-plain-plain'] ),
+ 'Block-only manifests should still allow scanner fallback for undeclared component files.'
+ );
+
+ echo "Block scoped asset smoke checks passed.\n";
+} catch ( Throwable $throwable ) {
+ fwrite( STDERR, $throwable->getMessage() . "\n" );
+ exit( 1 );
+} finally {
+ emulsify_block_assets_smoke_remove( $work_root );
+}
diff --git a/.github/scripts/bootstrap-loader-smoke.php b/.github/scripts/bootstrap-loader-smoke.php
new file mode 100644
index 0000000..48492ac
--- /dev/null
+++ b/.github/scripts/bootstrap-loader-smoke.php
@@ -0,0 +1,126 @@
+&1', $output, $status );
+ unlink( $script );
+
+ if ( 0 !== $status ) {
+ throw new RuntimeException( implode( "\n", $output ) );
+ }
+}
+
+$repo_root = dirname( __DIR__, 2 );
+$autoload = $repo_root . '/vendor/autoload.php';
+$classes = array(
+ 'Emulsify\\Theme\\Bootstrap',
+ 'Emulsify\\Theme\\Runtime\\Setup',
+ 'Emulsify\\Theme\\Runtime\\Assets',
+ 'Emulsify\\Theme\\Runtime\\Context',
+ 'Emulsify\\Theme\\Runtime\\Twig',
+ 'Emulsify\\Theme\\Runtime\\TimberIntegration',
+ 'Emulsify\\Theme\\Runtime\\MissingTimber',
+ 'Emulsify\\Theme\\Blocks\\Registry',
+ 'Emulsify\\Theme\\Blocks\\ComponentLocator',
+ 'Emulsify\\Theme\\Blocks\\AcfBlocks',
+ 'Emulsify\\Theme\\Blocks\\NativeBlocks',
+ 'Emulsify\\Theme\\Blocks\\CoreBlockTwigRenderer',
+ 'Emulsify\\Theme\\Blocks\\Patterns',
+ 'Emulsify\\Theme\\Editor\\AllowedBlockTypes',
+ 'Emulsify\\Theme\\Editor\\BlockNames',
+ 'Emulsify\\Theme\\Editor\\BlockSupportOverrides',
+ 'Emulsify\\Theme\\Editor\\Enhancements',
+ 'Emulsify\\Theme\\Editor\\PatternGovernance',
+ 'Emulsify\\Theme\\Editor\\Policy',
+ 'Emulsify\\Theme\\Editor\\PolicyOptions',
+ 'Emulsify\\Theme\\Editor\\UserPatternPermissions',
+ 'Emulsify\\Theme\\Acf\\LocalJson',
+ 'Emulsify\\Theme\\Cli\\GenerateChildThemeCommand',
+ 'Emulsify\\Theme\\Support\\AttributeBag',
+ 'Emulsify\\Theme\\Support\\AssetRecord',
+ 'Emulsify\\Theme\\Support\\AssetEnqueuer',
+ 'Emulsify\\Theme\\Support\\Diagnostics',
+ 'Emulsify\\Theme\\Support\\AssetManifest',
+ 'Emulsify\\Theme\\Support\\FileDiscovery',
+ 'Emulsify\\Theme\\Support\\ProjectConfig',
+);
+
+emulsify_bootstrap_loader_assert( is_readable( $autoload ), 'Run composer install or composer dump-autoload before the Bootstrap loader smoke test.' );
+
+$classes_export = var_export( $classes, true );
+$repo_export = var_export( $repo_root, true );
+
+emulsify_bootstrap_loader_run_php(
+ <<newInstanceWithoutConstructor();
+\$theme_dir = \$reflection->getProperty( 'theme_dir' );
+\$theme_dir->setAccessible( true );
+\$theme_dir->setValue( \$bootstrap, \$repo_root );
+\$load_classes = \$reflection->getMethod( 'load_classes' );
+\$load_classes->setAccessible( true );
+\$load_classes->invoke( \$bootstrap );
+
+foreach ( \$classes as \$class ) {
+ if ( ! class_exists( \$class, true ) ) {
+ throw new RuntimeException( sprintf( 'Fallback loader did not load %s.', \$class ) );
+ }
+}
+PHP
+);
+
+echo "Bootstrap loader smoke checks passed.\n";
diff --git a/.github/scripts/child-theme-generator-smoke.php b/.github/scripts/child-theme-generator-smoke.php
new file mode 100644
index 0000000..142ef2b
--- /dev/null
+++ b/.github/scripts/child-theme-generator-smoke.php
@@ -0,0 +1,585 @@
+
+ */
+ public static $messages = array();
+
+ public static function add_command( $name, $callable ): void {}
+
+ public static function log( string $message ): void {
+ self::$messages[] = array(
+ 'type' => 'log',
+ 'message' => $message,
+ );
+ }
+
+ public static function warning( string $message ): void {
+ self::$messages[] = array(
+ 'type' => 'warning',
+ 'message' => $message,
+ );
+ }
+
+ public static function success( string $message ): void {
+ self::$messages[] = array(
+ 'type' => 'success',
+ 'message' => $message,
+ );
+ }
+
+ public static function error( string $message ): void {
+ throw new RuntimeException( $message );
+ }
+ }
+}
+
+if ( ! function_exists( 'get_theme_root' ) ) {
+ function get_theme_root(): string {
+ return $GLOBALS['emulsify_theme_root'];
+ }
+}
+
+if ( ! function_exists( 'sanitize_text_field' ) ) {
+ function sanitize_text_field( string $value ): string {
+ return trim( strip_tags( $value ) );
+ }
+}
+
+if ( ! function_exists( 'sanitize_key' ) ) {
+ function sanitize_key( string $key ): string {
+ return preg_replace( '/[^a-z0-9_-]/', '', strtolower( $key ) );
+ }
+}
+
+if ( ! function_exists( 'wp_mkdir_p' ) ) {
+ function wp_mkdir_p( string $target ): bool {
+ $failure = $GLOBALS['emulsify_mkdir_failure'] ?? null;
+
+ if ( is_callable( $failure ) && $failure( $target ) ) {
+ return false;
+ }
+
+ return is_dir( $target ) || mkdir( $target, 0777, true );
+ }
+}
+
+if ( ! function_exists( 'switch_theme' ) ) {
+ function switch_theme( string $stylesheet ): void {
+ $GLOBALS['emulsify_activated_theme'] = $stylesheet;
+ }
+}
+
+/**
+ * Reads the expected generated source version from root package metadata.
+ *
+ * Root package.json is the single source of truth for the release version, so
+ * this smoke derives the value instead of restating it.
+ *
+ * @return string Expected generatedFromVersion.
+ */
+function emulsify_cli_smoke_expected_version(): string {
+ static $version = null;
+
+ if ( null !== $version ) {
+ return $version;
+ }
+
+ $contents = file_get_contents( dirname( __DIR__, 2 ) . '/package.json' );
+ $data = is_string( $contents ) ? json_decode( $contents, true ) : null;
+
+ if ( ! is_array( $data ) || ! isset( $data['version'] ) || ! is_string( $data['version'] ) || '' === trim( $data['version'] ) ) {
+ throw new RuntimeException( 'Could not read the release version from root package.json.' );
+ }
+
+ $version = trim( $data['version'] );
+
+ return $version;
+}
+
+/**
+ * Fails the smoke script when an assertion is false.
+ *
+ * @param bool $condition Assertion condition.
+ * @param string $message Failure message.
+ * @return void
+ */
+function emulsify_cli_smoke_assert( bool $condition, string $message ): void {
+ if ( ! $condition ) {
+ throw new RuntimeException( $message );
+ }
+}
+
+/**
+ * Recursively copies test fixtures.
+ *
+ * @param string $source Source path.
+ * @param string $destination Destination path.
+ * @return void
+ */
+function emulsify_cli_smoke_copy( string $source, string $destination ): void {
+ $iterator = new RecursiveIteratorIterator(
+ new RecursiveDirectoryIterator( $source, RecursiveDirectoryIterator::SKIP_DOTS ),
+ RecursiveIteratorIterator::SELF_FIRST
+ );
+
+ foreach ( $iterator as $item ) {
+ $path = $item->getPathname();
+ $relative = ltrim( substr( $path, strlen( rtrim( $source, '/\\' ) ) ), '/\\' );
+
+ if ( in_array( 'node_modules', preg_split( '#[\\\\/]#', $relative ), true ) ) {
+ continue;
+ }
+
+ $target = $destination . '/' . $relative;
+
+ if ( $item->isDir() ) {
+ if ( ! is_dir( $target ) && ! mkdir( $target, 0777, true ) ) {
+ throw new RuntimeException( sprintf( 'Could not create fixture directory: %s', $target ) );
+ }
+
+ continue;
+ }
+
+ if ( ! is_dir( dirname( $target ) ) && ! mkdir( dirname( $target ), 0777, true ) ) {
+ throw new RuntimeException( sprintf( 'Could not create fixture directory: %s', dirname( $target ) ) );
+ }
+
+ if ( ! copy( $path, $target ) ) {
+ throw new RuntimeException( sprintf( 'Could not copy fixture file: %s', $path ) );
+ }
+ }
+}
+
+/**
+ * Recursively removes a path.
+ *
+ * @param string $path Path to remove.
+ * @return void
+ */
+function emulsify_cli_smoke_remove( string $path ): void {
+ if ( ! file_exists( $path ) ) {
+ return;
+ }
+
+ if ( is_file( $path ) || is_link( $path ) ) {
+ unlink( $path );
+ return;
+ }
+
+ $iterator = new RecursiveIteratorIterator(
+ new RecursiveDirectoryIterator( $path, RecursiveDirectoryIterator::SKIP_DOTS ),
+ RecursiveIteratorIterator::CHILD_FIRST
+ );
+
+ foreach ( $iterator as $item ) {
+ $item->isDir() && ! $item->isLink() ? rmdir( $item->getPathname() ) : unlink( $item->getPathname() );
+ }
+
+ rmdir( $path );
+}
+
+/**
+ * Reads and decodes a JSON fixture file.
+ *
+ * @param string $path JSON file path.
+ * @return array Decoded JSON.
+ */
+function emulsify_cli_smoke_json( string $path ): array {
+ $data = json_decode( file_get_contents( $path ), true );
+
+ emulsify_cli_smoke_assert( is_array( $data ), sprintf( 'Could not decode JSON file: %s', $path ) );
+
+ return $data;
+}
+
+/**
+ * Runs PHP's syntax checker against a generated file when process execution is available.
+ *
+ * @param string $path PHP file path.
+ * @return void
+ */
+function emulsify_cli_smoke_lint_php( string $path ): void {
+ if ( ! function_exists( 'exec' ) || '' === PHP_BINARY ) {
+ return;
+ }
+
+ $output = array();
+ $exit_code = 0;
+ exec( escapeshellarg( PHP_BINARY ) . ' -l ' . escapeshellarg( $path ), $output, $exit_code );
+
+ emulsify_cli_smoke_assert(
+ 0 === $exit_code,
+ sprintf( "Generated PHP failed syntax validation:\n%s", implode( "\n", $output ) )
+ );
+}
+
+$repo_root = dirname( __DIR__, 2 );
+$work_root = sys_get_temp_dir() . '/emulsify-child-theme-generator-' . uniqid( '', true );
+$theme_root = $work_root . '/themes';
+$parent_root = $theme_root . '/emulsify';
+
+$GLOBALS['emulsify_theme_root'] = $theme_root;
+$GLOBALS['emulsify_activated_theme'] = null;
+$GLOBALS['emulsify_mkdir_failure'] = null;
+
+try {
+ if ( ! mkdir( $parent_root . '/whisk', 0777, true ) ) {
+ throw new RuntimeException( sprintf( 'Could not create fixture root: %s', $parent_root ) );
+ }
+
+ emulsify_cli_smoke_copy( $repo_root . '/whisk', $parent_root . '/whisk' );
+
+ // The installable archive omits root npm tooling, so the generator reads the
+ // synchronized release version from the parent theme's runtime metadata.
+ if ( ! copy( $repo_root . '/style.css', $parent_root . '/style.css' ) ) {
+ throw new RuntimeException( 'Could not copy the parent theme style.css fixture.' );
+ }
+
+ file_put_contents(
+ $parent_root . '/whisk/patterns/smoke-pattern.json',
+ json_encode(
+ array(
+ 'name' => 'whisk/smoke-pattern',
+ 'title' => 'Smoke Pattern',
+ 'description' => 'Temporary pattern fixture for child-theme generation smoke coverage.',
+ 'categories' => array( 'text' ),
+ 'content' => 'Smoke pattern content
',
+ ),
+ JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES
+ ) . "\n"
+ );
+
+ if ( ! mkdir( $parent_root . '/whisk/.cache', 0777, true ) ) {
+ throw new RuntimeException( 'Could not create ignored cache fixture.' );
+ }
+ file_put_contents( $parent_root . '/whisk/.cache/sentinel.txt', 'ignored' );
+
+ require_once $repo_root . '/includes/Cli/GenerateChildThemeCommand.php';
+
+ $cli = new Emulsify\Theme\Cli\GenerateChildThemeCommand();
+
+ $cli( array( 'Acme Theme' ), array( 'machine-name' => 'acme-child' ) );
+
+ $destination = $theme_root . '/acme-child';
+ $style = file_get_contents( $destination . '/style.css' );
+ $package = emulsify_cli_smoke_json( $destination . '/package.json' );
+ $project = emulsify_cli_smoke_json( $destination . '/project.emulsify.json' );
+ $page = file_get_contents( $destination . '/templates/page.twig' );
+ $functions = file_get_contents( $destination . '/functions.php' );
+ $smoke_pattern = emulsify_cli_smoke_json( $destination . '/patterns/smoke-pattern.json' );
+
+ emulsify_cli_smoke_assert( is_dir( $destination ), 'Expected generated child theme directory to exist.' );
+ emulsify_cli_smoke_assert( ! is_dir( $destination . '/node_modules' ), 'Generated child theme should not copy node_modules.' );
+ emulsify_cli_smoke_assert( is_file( $destination . '/config/acf-json/.gitkeep' ), 'Generated child theme should copy the ACF Local JSON convention directory.' );
+ emulsify_cli_smoke_assert( is_file( $destination . '/assets/images/.gitkeep' ), 'Generated child theme should copy the empty theme image asset directory.' );
+ emulsify_cli_smoke_assert( is_file( $destination . '/assets/icons/.gitkeep' ), 'Generated child theme should copy the empty theme icon asset directory.' );
+ emulsify_cli_smoke_assert( false !== strpos( $style, 'Theme Name: Acme Theme' ), 'style.css should update Theme Name.' );
+ emulsify_cli_smoke_assert( false !== strpos( $style, 'Text Domain: acme-child' ), 'style.css should update Text Domain.' );
+ emulsify_cli_smoke_assert( false !== strpos( $style, 'Template: emulsify' ), 'style.css should keep the parent Template slug.' );
+ emulsify_cli_smoke_assert( 'acme-child' === $package['name'], 'package.json should update name.' );
+ emulsify_cli_smoke_assert( 'wordpress' === $project['project']['platform'], 'project.emulsify.json should preserve the WordPress platform adapter.' );
+ emulsify_cli_smoke_assert( 'Acme Theme' === $project['project']['name'], 'project.emulsify.json should update project name.' );
+ emulsify_cli_smoke_assert( 'acme-child' === $project['project']['machineName'], 'project.emulsify.json should update machineName.' );
+ emulsify_cli_smoke_assert( 'emulsify-wordpress' === $project['project']['generatedFrom'], 'project.emulsify.json should identify the generated child theme source.' );
+ emulsify_cli_smoke_assert( emulsify_cli_smoke_expected_version() === $project['project']['generatedFromVersion'], 'project.emulsify.json should record the generated child theme source version.' );
+ emulsify_cli_smoke_assert( false !== strpos( $page, 'acme-child-page' ), 'Example template should update slug class.' );
+ emulsify_cli_smoke_assert( false === strpos( $page, 'whisk-page' ), 'Example template should not keep the whisk slug class.' );
+ emulsify_cli_smoke_assert( false !== strpos( $functions, 'Acme Theme child theme hooks.' ), 'functions.php should update visible Whisk label.' );
+ emulsify_cli_smoke_assert( is_file( $destination . '/src/components/.gitkeep' ), 'Generated child theme should keep the empty component source placeholder.' );
+ emulsify_cli_smoke_assert( is_file( $destination . '/patterns/.gitkeep' ), 'Generated child theme should keep the empty pattern placeholder.' );
+ emulsify_cli_smoke_assert( ! is_dir( $destination . '/src/components/button' ), 'Generated child theme should not include the removed starter button component.' );
+ emulsify_cli_smoke_assert( ! is_dir( $destination . '/src/editor' ), 'Generated child theme should not include assumed editor enhancement source modules.' );
+ emulsify_cli_smoke_assert( ! is_dir( $destination . '/src/foundation' ), 'Generated child theme should not include an assumed foundation source directory.' );
+ emulsify_cli_smoke_assert( ! is_dir( $destination . '/src/layout' ), 'Generated child theme should not include an assumed layout source directory.' );
+ emulsify_cli_smoke_assert( ! is_file( $destination . '/src/foundation.scss' ), 'Generated child theme should not include an assumed foundation Sass entry.' );
+ emulsify_cli_smoke_assert( ! is_file( $destination . '/src/layout.scss' ), 'Generated child theme should not include an assumed layout Sass entry.' );
+ emulsify_cli_smoke_assert( ! is_file( $destination . '/src/tokens.scss' ), 'Generated child theme should not include an assumed tokens Sass entry.' );
+ emulsify_cli_smoke_assert( ! is_file( $destination . '/theme.json' ), 'Generated child theme should not include an empty child theme.json by default.' );
+ emulsify_cli_smoke_assert( ! is_dir( $destination . '/dist' ), 'Generated child theme should not copy ignored build output directories.' );
+ emulsify_cli_smoke_assert( ! is_dir( $destination . '/.out' ), 'Generated child theme should not copy ignored Storybook output directories.' );
+ emulsify_cli_smoke_assert( ! is_dir( $destination . '/.cache' ), 'Generated child theme should not copy ignored cache directories.' );
+ emulsify_cli_smoke_assert( 'acme-child/smoke-pattern' === $smoke_pattern['name'], 'Generated child theme should update copied pattern namespaces when patterns exist.' );
+ emulsify_cli_smoke_assert( false !== strpos( $smoke_pattern['content'], 'Smoke pattern content' ), 'Generated child theme should copy optional pattern content when patterns exist.' );
+
+ $hostile_label = 'Hostile */ echo 1; /* Theme';
+ $hostile_destination = $theme_root . '/hostile-child';
+
+ $cli( array( $hostile_label ), array( 'machine-name' => 'hostile-child' ) );
+
+ $hostile_style = file_get_contents( $hostile_destination . '/style.css' );
+ $hostile_functions = file_get_contents( $hostile_destination . '/functions.php' );
+ $hostile_project = emulsify_cli_smoke_json( $hostile_destination . '/project.emulsify.json' );
+ $starter_functions = file_get_contents( $parent_root . '/whisk/functions.php' );
+
+ emulsify_cli_smoke_assert( false !== strpos( $hostile_style, 'Theme Name: Hostile echo 1 Theme' ), 'style.css should use a source-safe theme label.' );
+ emulsify_cli_smoke_assert( false === strpos( $hostile_style, $hostile_label ), 'style.css should not contain a crafted comment terminator.' );
+ emulsify_cli_smoke_assert( false !== strpos( $hostile_functions, 'Hostile echo 1 Theme child theme hooks.' ), 'functions.php should use a source-safe theme label.' );
+ emulsify_cli_smoke_assert( false === strpos( $hostile_functions, '*/ echo 1; /*' ), 'functions.php should not contain the attempted docblock breakout.' );
+ emulsify_cli_smoke_assert( substr_count( $starter_functions, '*/' ) === substr_count( $hostile_functions, '*/' ), 'functions.php should not gain an extra comment terminator.' );
+ emulsify_cli_smoke_assert( $hostile_label === $hostile_project['project']['name'], 'project.emulsify.json should preserve the richer label through JSON encoding.' );
+ emulsify_cli_smoke_lint_php( $hostile_destination . '/functions.php' );
+
+ $failed_without_force = false;
+
+ try {
+ $cli( array( 'Acme Theme' ), array( 'machine-name' => 'acme-child' ) );
+ } catch ( RuntimeException $exception ) {
+ $failed_without_force = false !== strpos( $exception->getMessage(), 'Destination already exists' );
+ }
+
+ emulsify_cli_smoke_assert( $failed_without_force, 'Existing destination should fail without --force.' );
+
+ file_put_contents( $destination . '/remove-me.txt', 'stale' );
+
+ $cli( array( 'Acme Theme' ), array( 'machine-name' => 'acme-child', 'force' => true ) );
+
+ $replaced_project = emulsify_cli_smoke_json( $destination . '/project.emulsify.json' );
+
+ emulsify_cli_smoke_assert( ! file_exists( $destination . '/remove-me.txt' ), '--force should replace the existing destination.' );
+ emulsify_cli_smoke_assert( is_file( $destination . '/project.emulsify.json' ), '--force should replace a generated Emulsify child theme with fresh project metadata.' );
+ emulsify_cli_smoke_assert( 'emulsify-wordpress' === $replaced_project['project']['generatedFrom'], '--force should keep generated child theme source metadata.' );
+ emulsify_cli_smoke_assert( emulsify_cli_smoke_expected_version() === $replaced_project['project']['generatedFromVersion'], '--force should keep generated child theme source version metadata.' );
+
+ $unrelated_destination = $theme_root . '/unrelated-theme';
+ $unrelated_refused = false;
+
+ if ( ! mkdir( $unrelated_destination, 0777, true ) ) {
+ throw new RuntimeException( sprintf( 'Could not create unrelated theme fixture: %s', $unrelated_destination ) );
+ }
+
+ file_put_contents( $unrelated_destination . '/style.css', "/*\n * Theme Name: Unrelated Theme\n * Template: twentytwentysix\n */\n" );
+ file_put_contents( $unrelated_destination . '/keep-me.txt', 'unrelated' );
+
+ try {
+ $cli( array( 'Unrelated Theme' ), array( 'machine-name' => 'unrelated-theme', 'force' => true ) );
+ } catch ( RuntimeException $exception ) {
+ $unrelated_refused = false !== strpos( $exception->getMessage(), 'Refusing to replace existing destination because it does not look like an Emulsify-generated child theme' );
+ }
+
+ emulsify_cli_smoke_assert( $unrelated_refused, '--force should refuse to replace an unrelated theme directory.' );
+ emulsify_cli_smoke_assert( is_file( $unrelated_destination . '/keep-me.txt' ), '--force refusal should not delete unrelated theme files.' );
+
+ $foreign_destination = $theme_root . '/foreign-theme';
+ $foreign_refused = false;
+
+ if ( ! mkdir( $foreign_destination, 0777, true ) ) {
+ throw new RuntimeException( sprintf( 'Could not create foreign generated theme fixture: %s', $foreign_destination ) );
+ }
+
+ file_put_contents( $foreign_destination . '/style.css', "/*\n * Theme Name: Foreign Theme\n * Template: emulsify\n */\n" );
+ file_put_contents(
+ $foreign_destination . '/project.emulsify.json',
+ json_encode(
+ array(
+ 'project' => array(
+ 'platform' => 'wordpress',
+ 'name' => 'Foreign Theme',
+ 'machineName' => 'foreign-theme',
+ 'generatedFrom' => 'foreign-generator',
+ 'generatedFromVersion' => '1.0.0',
+ ),
+ ),
+ JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES
+ ) . "\n"
+ );
+ file_put_contents( $foreign_destination . '/keep-me.txt', 'foreign' );
+
+ try {
+ $cli( array( 'Foreign Theme' ), array( 'machine-name' => 'foreign-theme', 'force' => true ) );
+ } catch ( RuntimeException $exception ) {
+ $foreign_refused = false !== strpos( $exception->getMessage(), 'generatedFrom is "foreign-generator"' );
+ }
+
+ emulsify_cli_smoke_assert( $foreign_refused, '--force should refuse to replace a theme generated by a different source.' );
+ emulsify_cli_smoke_assert( is_file( $foreign_destination . '/keep-me.txt' ), '--force generatedFrom refusal should not delete existing theme files.' );
+
+ $missing_lineage_destination = $theme_root . '/missing-lineage';
+ $missing_lineage_refused = false;
+
+ if ( ! mkdir( $missing_lineage_destination, 0777, true ) ) {
+ throw new RuntimeException( sprintf( 'Could not create missing-lineage theme fixture: %s', $missing_lineage_destination ) );
+ }
+
+ file_put_contents( $missing_lineage_destination . '/style.css', "/*\n * Theme Name: Missing Lineage\n * Template: emulsify\n */\n" );
+ file_put_contents(
+ $missing_lineage_destination . '/project.emulsify.json',
+ json_encode(
+ array(
+ 'project' => array(
+ 'platform' => 'wordpress',
+ 'name' => 'Missing Lineage',
+ 'machineName' => 'missing-lineage',
+ ),
+ ),
+ JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES
+ ) . "\n"
+ );
+ file_put_contents( $missing_lineage_destination . '/keep-me.txt', 'missing-lineage' );
+
+ try {
+ $cli( array( 'Missing Lineage' ), array( 'machine-name' => 'missing-lineage', 'force' => true ) );
+ } catch ( RuntimeException $exception ) {
+ $missing_lineage_refused = false !== strpos( $exception->getMessage(), 'missing project.generatedFrom' );
+ }
+
+ emulsify_cli_smoke_assert( $missing_lineage_refused, '--force should require generatedFrom lineage metadata.' );
+ emulsify_cli_smoke_assert( is_file( $missing_lineage_destination . '/keep-me.txt' ), '--force missing-lineage refusal should not delete existing theme files.' );
+
+ $missing_version_destination = $theme_root . '/missing-version';
+ $missing_version_refused = false;
+
+ if ( ! mkdir( $missing_version_destination, 0777, true ) ) {
+ throw new RuntimeException( sprintf( 'Could not create missing-version theme fixture: %s', $missing_version_destination ) );
+ }
+
+ file_put_contents( $missing_version_destination . '/style.css', "/*\n * Theme Name: Missing Version\n * Template: emulsify\n */\n" );
+ file_put_contents(
+ $missing_version_destination . '/project.emulsify.json',
+ json_encode(
+ array(
+ 'project' => array(
+ 'platform' => 'wordpress',
+ 'name' => 'Missing Version',
+ 'machineName' => 'missing-version',
+ 'generatedFrom' => 'emulsify-wordpress',
+ ),
+ ),
+ JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES
+ ) . "\n"
+ );
+ file_put_contents( $missing_version_destination . '/keep-me.txt', 'missing-version' );
+
+ try {
+ $cli( array( 'Missing Version' ), array( 'machine-name' => 'missing-version', 'force' => true ) );
+ } catch ( RuntimeException $exception ) {
+ $missing_version_refused = false !== strpos( $exception->getMessage(), 'missing project.generatedFromVersion' );
+ }
+
+ emulsify_cli_smoke_assert( $missing_version_refused, '--force should require generatedFromVersion lineage metadata.' );
+ emulsify_cli_smoke_assert( is_file( $missing_version_destination . '/keep-me.txt' ), '--force missing-version refusal should not delete existing theme files.' );
+
+ $mismatch_destination = $theme_root . '/mismatch-theme';
+ $mismatch_refused = false;
+
+ if ( ! mkdir( $mismatch_destination, 0777, true ) ) {
+ throw new RuntimeException( sprintf( 'Could not create machine-name mismatch fixture: %s', $mismatch_destination ) );
+ }
+
+ file_put_contents( $mismatch_destination . '/style.css', "/*\n * Theme Name: Mismatch Theme\n * Template: emulsify\n */\n" );
+ file_put_contents(
+ $mismatch_destination . '/project.emulsify.json',
+ json_encode(
+ array(
+ 'project' => array(
+ 'platform' => 'wordpress',
+ 'name' => 'Mismatch Theme',
+ 'machineName' => 'different-machine-name',
+ 'generatedFrom' => 'emulsify-wordpress',
+ 'generatedFromVersion' => emulsify_cli_smoke_expected_version(),
+ ),
+ ),
+ JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES
+ ) . "\n"
+ );
+ file_put_contents( $mismatch_destination . '/keep-me.txt', 'mismatch' );
+
+ try {
+ $cli( array( 'Mismatch Theme' ), array( 'machine-name' => 'mismatch-theme', 'force' => true ) );
+ } catch ( RuntimeException $exception ) {
+ $mismatch_refused = false !== strpos( $exception->getMessage(), 'machineName is "different-machine-name", expected "mismatch-theme"' );
+ }
+
+ emulsify_cli_smoke_assert( $mismatch_refused, '--force should require project.machineName to match the requested machine name.' );
+ emulsify_cli_smoke_assert( is_file( $mismatch_destination . '/keep-me.txt' ), '--force machine-name refusal should not delete existing theme files.' );
+
+ $rollback_destination = $theme_root . '/rollback-child';
+ $rollback_failed = false;
+
+ $cli( array( 'Rollback Theme' ), array( 'machine-name' => 'rollback-child' ) );
+ file_put_contents( $rollback_destination . '/keep-me.txt', 'original-theme' );
+
+ $GLOBALS['emulsify_mkdir_failure'] = static function ( string $target ): bool {
+ $normalized = str_replace( '\\', '/', $target );
+ return false !== strpos( $normalized, '/rollback-child.tmp-' ) && str_ends_with( $normalized, '/templates' );
+ };
+
+ try {
+ $cli( array( 'Rollback Theme' ), array( 'machine-name' => 'rollback-child', 'force' => true ) );
+ } catch ( RuntimeException $exception ) {
+ $rollback_failed = false !== strpos( $exception->getMessage(), 'Failed staging child theme' );
+ } finally {
+ $GLOBALS['emulsify_mkdir_failure'] = null;
+ }
+
+ emulsify_cli_smoke_assert( $rollback_failed, 'A mid-copy failure should abort generation.' );
+ emulsify_cli_smoke_assert( 'original-theme' === file_get_contents( $rollback_destination . '/keep-me.txt' ), 'A mid-copy failure should leave the original theme intact.' );
+ emulsify_cli_smoke_assert( array() === glob( $rollback_destination . '.tmp-*' ), 'A mid-copy failure should remove the partial staging directory.' );
+ emulsify_cli_smoke_assert( array() === glob( $rollback_destination . '.bak-*' ), 'A mid-copy failure should not leave a backup directory.' );
+
+ WP_CLI::$messages = array();
+ $GLOBALS['emulsify_activated_theme'] = null;
+ $dry_run_destination = $theme_root . '/dry-run-child';
+
+ $cli( array( 'Dry Run Theme' ), array( 'machine-name' => 'dry-run-child' ) );
+ file_put_contents( $dry_run_destination . '/keep-me.txt', 'dry-run' );
+ WP_CLI::$messages = array();
+
+ $cli( array( 'Dry Run Theme' ), array( 'machine-name' => 'dry-run-child', 'dry-run' => true, 'force' => true, 'activate' => true ) );
+
+ emulsify_cli_smoke_assert( is_file( $dry_run_destination . '/keep-me.txt' ), '--dry-run --force should not delete an existing child theme directory.' );
+ emulsify_cli_smoke_assert( null === $GLOBALS['emulsify_activated_theme'], '--dry-run should not activate the child theme.' );
+ emulsify_cli_smoke_assert(
+ (bool) array_filter(
+ WP_CLI::$messages,
+ static function ( array $message ): bool {
+ return false !== strpos( $message['message'], 'Would replace existing destination because --force was provided' );
+ }
+ ),
+ '--dry-run --force should report that it would replace the existing destination.'
+ );
+ emulsify_cli_smoke_assert(
+ (bool) array_filter(
+ WP_CLI::$messages,
+ static function ( array $message ): bool {
+ return false !== strpos( $message['message'], 'Dry run complete' );
+ }
+ ),
+ '--dry-run should report completion.'
+ );
+
+ $invalid_machine_name_failed = false;
+
+ try {
+ $cli( array( 'Invalid Theme' ), array( 'machine-name' => '!!!' ) );
+ } catch ( RuntimeException $exception ) {
+ $invalid_machine_name_failed = false !== strpos( $exception->getMessage(), 'Machine name cannot be empty' );
+ }
+
+ emulsify_cli_smoke_assert( $invalid_machine_name_failed, 'Invalid --machine-name values should fail.' );
+
+ $cli( array( 'Active Theme' ), array( 'machine-name' => 'active-child', 'activate' => true ) );
+
+ emulsify_cli_smoke_assert( 'active-child' === $GLOBALS['emulsify_activated_theme'], '--activate should call switch_theme() with the child slug.' );
+
+ echo "Child theme generator smoke checks passed.\n";
+} catch ( Throwable $throwable ) {
+ fwrite( STDERR, $throwable->getMessage() . "\n" );
+ exit( 1 );
+} finally {
+ emulsify_cli_smoke_remove( $work_root );
+}
diff --git a/.github/scripts/component-locator-smoke.php b/.github/scripts/component-locator-smoke.php
new file mode 100644
index 0000000..955a385
--- /dev/null
+++ b/.github/scripts/component-locator-smoke.php
@@ -0,0 +1,618 @@
+ $accepted_args,
+ 'callback' => $callback,
+ );
+
+ ksort( $GLOBALS['emulsify_locator_smoke_hooks'][ $hook ] );
+
+ return true;
+ }
+}
+
+if ( ! function_exists( 'apply_filters' ) ) {
+ function apply_filters( string $hook, $value, ...$arguments ) {
+ if ( empty( $GLOBALS['emulsify_locator_smoke_hooks'][ $hook ] ) ) {
+ return $value;
+ }
+
+ foreach ( $GLOBALS['emulsify_locator_smoke_hooks'][ $hook ] as $callbacks ) {
+ foreach ( $callbacks as $callback ) {
+ $value = call_user_func_array(
+ $callback['callback'],
+ array_slice(
+ array_merge( array( $value ), $arguments ),
+ 0,
+ $callback['accepted_args']
+ )
+ );
+ }
+ }
+
+ return $value;
+ }
+}
+
+if ( ! function_exists( 'get_stylesheet' ) ) {
+ function get_stylesheet(): string {
+ return $GLOBALS['emulsify_locator_stylesheet'];
+ }
+}
+
+if ( ! function_exists( 'get_stylesheet_directory' ) ) {
+ function get_stylesheet_directory(): string {
+ return $GLOBALS['emulsify_locator_child_theme'];
+ }
+}
+
+if ( ! function_exists( 'get_template' ) ) {
+ function get_template(): string {
+ return $GLOBALS['emulsify_locator_template'];
+ }
+}
+
+if ( ! function_exists( 'get_template_directory' ) ) {
+ function get_template_directory(): string {
+ return $GLOBALS['emulsify_locator_parent_theme'];
+ }
+}
+
+if ( ! function_exists( 'wp_get_theme' ) ) {
+ function wp_get_theme( string $stylesheet = '' ) {
+ return new class( $stylesheet ) {
+ /**
+ * Theme stylesheet slug.
+ *
+ * @var string
+ */
+ private $stylesheet;
+
+ /**
+ * Constructor.
+ *
+ * @param string $stylesheet Theme stylesheet slug.
+ */
+ public function __construct( string $stylesheet ) {
+ $this->stylesheet = $stylesheet;
+ }
+
+ /**
+ * Gets theme metadata.
+ *
+ * @param string $header Metadata header.
+ * @return string Metadata value.
+ */
+ public function get( string $header ): string {
+ if ( 'Version' !== $header ) {
+ return '';
+ }
+
+ return isset( $GLOBALS['emulsify_locator_theme_versions'][ $this->stylesheet ] )
+ ? $GLOBALS['emulsify_locator_theme_versions'][ $this->stylesheet ]
+ : '';
+ }
+ };
+ }
+}
+
+if ( ! function_exists( 'wp_get_environment_type' ) ) {
+ function wp_get_environment_type(): string {
+ return $GLOBALS['emulsify_locator_environment'];
+ }
+}
+
+if ( ! function_exists( 'get_transient' ) ) {
+ function get_transient( string $key ) {
+ if ( array_key_exists( 'emulsify_locator_transient_override', $GLOBALS ) ) {
+ return $GLOBALS['emulsify_locator_transient_override'];
+ }
+
+ return array_key_exists( $key, $GLOBALS['emulsify_locator_transients'] )
+ ? $GLOBALS['emulsify_locator_transients'][ $key ]
+ : false;
+ }
+}
+
+if ( ! function_exists( 'set_transient' ) ) {
+ function set_transient( string $key, $value, int $expiration = 0 ): bool {
+ unset( $expiration );
+
+ $GLOBALS['emulsify_locator_transients'][ $key ] = $value;
+
+ return true;
+ }
+}
+
+if ( ! function_exists( 'delete_transient' ) ) {
+ function delete_transient( string $key ): bool {
+ $exists = array_key_exists( $key, $GLOBALS['emulsify_locator_transients'] );
+ unset( $GLOBALS['emulsify_locator_transients'][ $key ] );
+
+ return $exists;
+ }
+}
+
+if ( ! function_exists( 'sanitize_title' ) ) {
+ function sanitize_title( string $title ): string {
+ $slug = preg_replace( '/[^a-z0-9]+/i', '-', strtolower( $title ) );
+ $slug = trim( (string) $slug, '-' );
+
+ return $slug;
+ }
+}
+
+if ( ! function_exists( 'acf_register_block_type' ) ) {
+ function acf_register_block_type( array $args ) {
+ $GLOBALS['emulsify_locator_acf_registered'][] = $args;
+
+ return $args;
+ }
+}
+
+if ( ! function_exists( 'acf_get_block_type' ) ) {
+ function acf_get_block_type( string $name ) {
+ return isset( $GLOBALS['emulsify_locator_existing_acf_blocks'][ $name ] )
+ ? $GLOBALS['emulsify_locator_existing_acf_blocks'][ $name ]
+ : null;
+ }
+}
+
+/**
+ * Fails the smoke script when an assertion is false.
+ *
+ * @param bool $condition Assertion condition.
+ * @param string $message Failure message.
+ * @return void
+ */
+function emulsify_locator_smoke_assert( bool $condition, string $message ): void {
+ if ( ! $condition ) {
+ throw new RuntimeException( $message );
+ }
+}
+
+/**
+ * Writes a fixture file, creating its parent directory when needed.
+ *
+ * @param string $path File path.
+ * @param string $contents File contents.
+ * @return void
+ */
+function emulsify_locator_smoke_write( string $path, string $contents ): void {
+ $directory = dirname( $path );
+
+ if ( ! is_dir( $directory ) && ! mkdir( $directory, 0777, true ) ) {
+ throw new RuntimeException( sprintf( 'Could not create fixture directory: %s', $directory ) );
+ }
+
+ if ( false === file_put_contents( $path, $contents ) ) {
+ throw new RuntimeException( sprintf( 'Could not write fixture file: %s', $path ) );
+ }
+}
+
+/**
+ * Recursively removes a path.
+ *
+ * @param string $path Path to remove.
+ * @return void
+ */
+function emulsify_locator_smoke_remove( string $path ): void {
+ if ( ! file_exists( $path ) ) {
+ return;
+ }
+
+ if ( is_file( $path ) || is_link( $path ) ) {
+ unlink( $path );
+ return;
+ }
+
+ $iterator = new RecursiveIteratorIterator(
+ new RecursiveDirectoryIterator( $path, RecursiveDirectoryIterator::SKIP_DOTS ),
+ RecursiveIteratorIterator::CHILD_FIRST
+ );
+
+ foreach ( $iterator as $item ) {
+ $item->isDir() && ! $item->isLink() ? rmdir( $item->getPathname() ) : unlink( $item->getPathname() );
+ }
+
+ rmdir( $path );
+}
+
+/**
+ * Gets a list of record relative paths.
+ *
+ * @param array $records Component records.
+ * @return array Relative paths.
+ */
+function emulsify_locator_smoke_relatives( array $records ): array {
+ return array_map(
+ static function ( array $record ): string {
+ return $record['relative'];
+ },
+ $records
+ );
+}
+
+/**
+ * Checks whether a duplicate record was captured.
+ *
+ * @param array $duplicates Duplicate records.
+ * @param string $type Duplicate type.
+ * @param string $name Duplicate name/key.
+ * @return bool TRUE when a matching record exists.
+ */
+function emulsify_locator_smoke_has_duplicate( array $duplicates, string $type, string $name ): bool {
+ foreach ( $duplicates as $duplicate ) {
+ if ( $type === $duplicate['type'] && $name === $duplicate['name'] ) {
+ return true;
+ }
+ }
+
+ return false;
+}
+
+/**
+ * Gets registered ACF block names from the smoke stub.
+ *
+ * @return array Registered ACF block names.
+ */
+function emulsify_locator_smoke_acf_registered_names(): array {
+ return array_map(
+ static function ( array $args ): string {
+ return $args['name'];
+ },
+ $GLOBALS['emulsify_locator_acf_registered']
+ );
+}
+
+$repo_root = dirname( __DIR__, 2 );
+$work_root = sys_get_temp_dir() . '/emulsify-component-locator-' . uniqid( '', true );
+$child = $work_root . '/child-theme';
+$parent = $work_root . '/parent-theme';
+
+$GLOBALS['emulsify_locator_child_theme'] = $child;
+$GLOBALS['emulsify_locator_parent_theme'] = $parent;
+$GLOBALS['emulsify_locator_stylesheet'] = 'child-theme';
+$GLOBALS['emulsify_locator_template'] = 'parent-theme';
+$GLOBALS['emulsify_locator_theme_versions'] = array(
+ 'child-theme' => '1.0.0',
+ 'parent-theme' => '2.0.0',
+);
+$GLOBALS['emulsify_locator_acf_registered'] = array();
+$GLOBALS['emulsify_locator_existing_acf_blocks'] = array();
+
+try {
+ emulsify_locator_smoke_write( $child . '/dist/components/card/card.component.json', '{"title":"Child Card"}' );
+ emulsify_locator_smoke_write( $child . '/dist/components/card/card.twig', 'Child card' );
+ emulsify_locator_smoke_write( $child . '/dist/components/icon-card/icon-card.component.json', '{"title":"Child Icon Card"}' );
+ emulsify_locator_smoke_write( $child . '/dist/components/icon-card/icon-card.twig', 'Child icon card' );
+ emulsify_locator_smoke_write( $parent . '/dist/components/card/card.component.json', '{"title":"Parent Card"}' );
+ emulsify_locator_smoke_write( $parent . '/dist/components/card/card.twig', 'Parent card' );
+ emulsify_locator_smoke_write( $parent . '/dist/components/icon/card/card.component.json', '{"title":"Parent Icon Card"}' );
+ emulsify_locator_smoke_write( $parent . '/dist/components/icon/card/card.twig', 'Parent icon card' );
+ emulsify_locator_smoke_write( $parent . '/dist/components/parent-card/parent-card.component.json', '{"title":"Parent Only"}' );
+ emulsify_locator_smoke_write( $parent . '/dist/components/parent-card/parent-card.twig', 'Parent only' );
+ emulsify_locator_smoke_write( $child . '/dist/components/native/block.json', '{"name":"emulsify/native"}' );
+ emulsify_locator_smoke_write( $parent . '/dist/components/duplicate-native/block.json', '{"name":"emulsify/native"}' );
+ emulsify_locator_smoke_write( $parent . '/dist/components/native/block.json', '{"name":"emulsify/native-parent"}' );
+ emulsify_locator_smoke_write( $parent . '/dist/components/parent-native/block.json', '{"name":"emulsify/parent-native"}' );
+
+ require_once $repo_root . '/includes/Support/FileDiscovery.php';
+ require_once $repo_root . '/includes/Support/AssetRecord.php';
+ require_once $repo_root . '/includes/Support/Diagnostics.php';
+ require_once $repo_root . '/includes/Blocks/ComponentLocator.php';
+
+ $locator = new Emulsify\Theme\Blocks\ComponentLocator();
+ $acf = $locator->acf_components();
+
+ emulsify_locator_smoke_assert(
+ array( 'card', 'icon-card', 'parent-card' ) === emulsify_locator_smoke_relatives( $acf ),
+ 'ACF/Twig discovery should be deterministic and child-theme-first.'
+ );
+ emulsify_locator_smoke_assert(
+ false !== strpos( $acf[0]['metadata_path'], '/child-theme/dist/components/card/card.component.json' ),
+ 'Child ACF/Twig component metadata should override a parent component at the same relative path.'
+ );
+ emulsify_locator_smoke_assert(
+ 'dist/components/card/card.twig' === $acf[0]['template'],
+ 'ACF/Twig discovery should return the theme-relative Twig template.'
+ );
+
+ emulsify_locator_smoke_write( $child . '/dist/components/late-native/block.json', '{"name":"emulsify/late-native"}' );
+
+ $native = $locator->native_block_directories();
+
+ emulsify_locator_smoke_assert(
+ array( 'native', 'parent-native' ) === emulsify_locator_smoke_relatives( $native ),
+ 'Native block discovery should reuse the memoized file index and remain child-theme-first.'
+ );
+ emulsify_locator_smoke_assert(
+ false !== strpos( $native[0]['path'], '/child-theme/dist/components/native' ),
+ 'Child native block metadata should override a parent block at the same relative path.'
+ );
+
+ $locator_duplicates = $locator->skipped_duplicates();
+
+ emulsify_locator_smoke_assert(
+ emulsify_locator_smoke_has_duplicate( $locator_duplicates, 'acf_component_path', 'card' ),
+ 'ACF/Twig discovery should report duplicate component paths.'
+ );
+ emulsify_locator_smoke_assert(
+ emulsify_locator_smoke_has_duplicate( $locator_duplicates, 'acf_component_slug', 'icon-card' ),
+ 'ACF/Twig discovery should report duplicate component slugs.'
+ );
+ emulsify_locator_smoke_assert(
+ emulsify_locator_smoke_has_duplicate( $locator_duplicates, 'native_component_path', 'native' ),
+ 'Native discovery should report duplicate component paths.'
+ );
+ emulsify_locator_smoke_assert(
+ emulsify_locator_smoke_has_duplicate( $locator_duplicates, 'native_block_name', 'emulsify/native' ),
+ 'Native discovery should report duplicate block.json name values.'
+ );
+
+ emulsify_locator_smoke_write( $child . '/dist/components/late-card/late-card.component.json', '{"title":"Late Card"}' );
+ emulsify_locator_smoke_write( $child . '/dist/components/late-card/late-card.twig', 'Late card' );
+
+ emulsify_locator_smoke_assert(
+ array( 'card', 'icon-card', 'parent-card' ) === emulsify_locator_smoke_relatives( $locator->acf_components() ),
+ 'ACF/Twig discovery should return the per-request memoized result on repeated calls.'
+ );
+
+ $fresh_locator = new Emulsify\Theme\Blocks\ComponentLocator();
+
+ emulsify_locator_smoke_assert(
+ in_array( 'late-card', emulsify_locator_smoke_relatives( $fresh_locator->acf_components() ), true ),
+ 'A fresh locator instance should see files that were not present during the previous locator scan.'
+ );
+ emulsify_locator_smoke_assert(
+ in_array( 'late-native', emulsify_locator_smoke_relatives( $fresh_locator->native_block_directories() ), true ),
+ 'A fresh locator instance should see native blocks that were not present during the previous locator scan.'
+ );
+
+ emulsify_locator_smoke_write( $child . '/dist/components/acf-name-a/acf-name-a.component.json', '{"name":"emulsify/shared-acf","title":"Shared A"}' );
+ emulsify_locator_smoke_write( $child . '/dist/components/acf-name-a/acf-name-a.twig', 'Shared A' );
+ emulsify_locator_smoke_write( $child . '/dist/components/acf-name-b/acf-name-b.component.json', '{"name":"emulsify/shared-acf","title":"Shared B"}' );
+ emulsify_locator_smoke_write( $child . '/dist/components/acf-name-b/acf-name-b.twig', 'Shared B' );
+
+ require_once $repo_root . '/includes/Support/AssetEnqueuer.php';
+ require_once $repo_root . '/includes/Support/Diagnostics.php';
+ require_once $repo_root . '/includes/Support/AssetManifest.php';
+ require_once $repo_root . '/includes/Blocks/AcfBlocks.php';
+
+ $acf_blocks = new Emulsify\Theme\Blocks\AcfBlocks( new Emulsify\Theme\Blocks\ComponentLocator() );
+ $acf_blocks->register_blocks();
+ $registered_names = emulsify_locator_smoke_acf_registered_names();
+
+ emulsify_locator_smoke_assert(
+ 1 === count( array_keys( $registered_names, 'emulsify-shared-acf', true ) ),
+ 'ACF block registration should normalize and skip duplicate final block names.'
+ );
+ emulsify_locator_smoke_assert(
+ emulsify_locator_smoke_has_duplicate( $acf_blocks->skipped_duplicates(), 'acf_block_name', 'emulsify-shared-acf' ),
+ 'ACF block registration should report duplicate normalized final block names.'
+ );
+
+ $registered_card = null;
+
+ foreach ( $GLOBALS['emulsify_locator_acf_registered'] as $registered_block ) {
+ if ( 'emulsify-card' === $registered_block['name'] ) {
+ $registered_card = $registered_block;
+ break;
+ }
+ }
+
+ emulsify_locator_smoke_assert(
+ is_array( $registered_card ) && empty( $registered_card['data']['twig_template'] ),
+ 'ACF block registration should not persist the discovered Twig template in block data.'
+ );
+
+ $template_method = new ReflectionMethod( $acf_blocks, 'template' );
+
+ emulsify_locator_smoke_assert(
+ 'dist/components/card/card.twig' === $template_method->invoke(
+ $acf_blocks,
+ array(
+ 'twig_template' => 'dist/components/card/card.twig',
+ 'data' => array(
+ 'twig_template' => 'dist/components/parent-card/parent-card.twig',
+ ),
+ )
+ ),
+ 'ACF block rendering should prefer the registered Twig template over persisted block data.'
+ );
+ emulsify_locator_smoke_assert(
+ 'dist/components/parent-card/parent-card.twig' === $template_method->invoke(
+ $acf_blocks,
+ array(
+ 'data' => array(
+ 'twig_template' => 'dist/components/parent-card/parent-card.twig',
+ ),
+ )
+ ),
+ 'ACF block rendering should allow a legacy persisted template only when component discovery found it.'
+ );
+ emulsify_locator_smoke_assert(
+ '' === $template_method->invoke(
+ $acf_blocks,
+ array(
+ 'data' => array(
+ 'twig_template' => '../../hostile.twig',
+ ),
+ )
+ ),
+ 'ACF block rendering should reject persisted templates outside the discovered component set.'
+ );
+
+ add_filter(
+ 'emulsify_theme_component_discovery_cache_enabled',
+ static function (): bool {
+ return true;
+ }
+ );
+ add_filter(
+ 'emulsify_theme_component_discovery_cache_key_parts',
+ static function ( array $key_parts ): array {
+ $key_parts['smoke'] = 'component-locator';
+
+ return $key_parts;
+ }
+ );
+ add_filter(
+ 'emulsify_theme_component_discovery_cache_ttl',
+ static function (): int {
+ return 3600;
+ }
+ );
+
+ $cache_child = $work_root . '/cache-child';
+ $cache_parent = $work_root . '/cache-parent';
+ $GLOBALS['emulsify_locator_child_theme'] = $cache_child;
+ $GLOBALS['emulsify_locator_parent_theme'] = $cache_parent;
+ $GLOBALS['emulsify_locator_stylesheet'] = 'cache-child';
+ $GLOBALS['emulsify_locator_template'] = 'cache-parent';
+ $GLOBALS['emulsify_locator_theme_versions'] = array(
+ 'cache-child' => '1.0.0',
+ 'cache-parent' => '2.0.0',
+ );
+ $GLOBALS['emulsify_locator_transients'] = array();
+ emulsify_locator_smoke_write( $cache_child . '/dist/components/cache-card/cache-card.component.json', '{"title":"Cache Card"}' );
+ emulsify_locator_smoke_write( $cache_child . '/dist/components/cache-card/cache-card.twig', 'Cache card' );
+
+ $cache_locator = new Emulsify\Theme\Blocks\ComponentLocator();
+
+ emulsify_locator_smoke_assert(
+ array( 'cache-card' ) === emulsify_locator_smoke_relatives( $cache_locator->acf_components() ),
+ 'Missing persistent discovery cache should fall back to filesystem scanning.'
+ );
+
+ emulsify_locator_smoke_write( $cache_child . '/dist/components/cache-late/cache-late.component.json', '{"title":"Cache Late"}' );
+ emulsify_locator_smoke_write( $cache_child . '/dist/components/cache-late/cache-late.twig', 'Cache late' );
+
+ $cached_locator = new Emulsify\Theme\Blocks\ComponentLocator();
+
+ emulsify_locator_smoke_assert(
+ ! in_array( 'cache-late', emulsify_locator_smoke_relatives( $cached_locator->acf_components() ), true ),
+ 'Enabled persistent discovery cache should be reused across locator instances.'
+ );
+ emulsify_locator_smoke_assert(
+ Emulsify\Theme\Blocks\ComponentLocator::clear_discovery_cache(),
+ 'Component discovery cache clear method should delete the active transient.'
+ );
+
+ $cleared_locator = new Emulsify\Theme\Blocks\ComponentLocator();
+
+ emulsify_locator_smoke_assert(
+ in_array( 'cache-late', emulsify_locator_smoke_relatives( $cleared_locator->acf_components() ), true ),
+ 'Clearing persistent discovery cache should force the next locator to scan.'
+ );
+
+ $version_child = $work_root . '/version-child';
+ $version_parent = $work_root . '/version-parent';
+ $GLOBALS['emulsify_locator_child_theme'] = $version_child;
+ $GLOBALS['emulsify_locator_parent_theme'] = $version_parent;
+ $GLOBALS['emulsify_locator_stylesheet'] = 'version-child';
+ $GLOBALS['emulsify_locator_template'] = 'version-parent';
+ $GLOBALS['emulsify_locator_theme_versions'] = array(
+ 'version-child' => '1.0.0',
+ 'version-parent' => '2.0.0',
+ );
+ $GLOBALS['emulsify_locator_transients'] = array();
+ emulsify_locator_smoke_write( $version_child . '/dist/components/version-card/version-card.component.json', '{"title":"Version Card"}' );
+ emulsify_locator_smoke_write( $version_child . '/dist/components/version-card/version-card.twig', 'Version card' );
+ ( new Emulsify\Theme\Blocks\ComponentLocator() )->acf_components();
+ emulsify_locator_smoke_write( $version_child . '/dist/components/version-late/version-late.component.json', '{"title":"Version Late"}' );
+ emulsify_locator_smoke_write( $version_child . '/dist/components/version-late/version-late.twig', 'Version late' );
+
+ emulsify_locator_smoke_assert(
+ ! in_array( 'version-late', emulsify_locator_smoke_relatives( ( new Emulsify\Theme\Blocks\ComponentLocator() )->acf_components() ), true ),
+ 'Persistent discovery cache should hold until an invalidating key part changes.'
+ );
+
+ $GLOBALS['emulsify_locator_theme_versions']['version-child'] = '1.0.1';
+
+ emulsify_locator_smoke_assert(
+ in_array( 'version-late', emulsify_locator_smoke_relatives( ( new Emulsify\Theme\Blocks\ComponentLocator() )->acf_components() ), true ),
+ 'Changing the child theme version should change the persistent discovery cache key.'
+ );
+
+ $mtime_child = $work_root . '/mtime-child';
+ $mtime_parent = $work_root . '/mtime-parent';
+ $GLOBALS['emulsify_locator_child_theme'] = $mtime_child;
+ $GLOBALS['emulsify_locator_parent_theme'] = $mtime_parent;
+ $GLOBALS['emulsify_locator_stylesheet'] = 'mtime-child';
+ $GLOBALS['emulsify_locator_template'] = 'mtime-parent';
+ $GLOBALS['emulsify_locator_theme_versions'] = array(
+ 'mtime-child' => '1.0.0',
+ 'mtime-parent' => '2.0.0',
+ );
+ $GLOBALS['emulsify_locator_transients'] = array();
+ emulsify_locator_smoke_write( $mtime_child . '/dist/emulsify-assets.json', '{"assets":{}}' );
+ emulsify_locator_smoke_write( $mtime_child . '/dist/components/mtime-card/mtime-card.component.json', '{"title":"Mtime Card"}' );
+ emulsify_locator_smoke_write( $mtime_child . '/dist/components/mtime-card/mtime-card.twig', 'Mtime card' );
+ ( new Emulsify\Theme\Blocks\ComponentLocator() )->acf_components();
+ emulsify_locator_smoke_write( $mtime_child . '/dist/components/mtime-late/mtime-late.component.json', '{"title":"Mtime Late"}' );
+ emulsify_locator_smoke_write( $mtime_child . '/dist/components/mtime-late/mtime-late.twig', 'Mtime late' );
+
+ emulsify_locator_smoke_assert(
+ ! in_array( 'mtime-late', emulsify_locator_smoke_relatives( ( new Emulsify\Theme\Blocks\ComponentLocator() )->acf_components() ), true ),
+ 'Persistent discovery cache should include the manifest mtime in its key.'
+ );
+
+ touch( $mtime_child . '/dist/emulsify-assets.json', time() + 10 );
+ clearstatcache( true, $mtime_child . '/dist/emulsify-assets.json' );
+
+ emulsify_locator_smoke_assert(
+ in_array( 'mtime-late', emulsify_locator_smoke_relatives( ( new Emulsify\Theme\Blocks\ComponentLocator() )->acf_components() ), true ),
+ 'Changing the asset manifest mtime should change the persistent discovery cache key.'
+ );
+
+ $invalid_child = $work_root . '/invalid-cache-child';
+ $invalid_parent = $work_root . '/invalid-cache-parent';
+ $GLOBALS['emulsify_locator_child_theme'] = $invalid_child;
+ $GLOBALS['emulsify_locator_parent_theme'] = $invalid_parent;
+ $GLOBALS['emulsify_locator_stylesheet'] = 'invalid-cache-child';
+ $GLOBALS['emulsify_locator_template'] = 'invalid-cache-parent';
+ $GLOBALS['emulsify_locator_theme_versions'] = array(
+ 'invalid-cache-child' => '1.0.0',
+ 'invalid-cache-parent' => '2.0.0',
+ );
+ $GLOBALS['emulsify_locator_transients'] = array();
+ $GLOBALS['emulsify_locator_transient_override'] = array(
+ 'files' => array(
+ array(
+ 'path' => $invalid_child . '/broken.component.json',
+ ),
+ ),
+ );
+ emulsify_locator_smoke_write( $invalid_child . '/dist/components/invalid-card/invalid-card.component.json', '{"title":"Invalid Card"}' );
+ emulsify_locator_smoke_write( $invalid_child . '/dist/components/invalid-card/invalid-card.twig', 'Invalid card' );
+
+ emulsify_locator_smoke_assert(
+ array( 'invalid-card' ) === emulsify_locator_smoke_relatives( ( new Emulsify\Theme\Blocks\ComponentLocator() )->acf_components() ),
+ 'Invalid persistent discovery cache data should fall back to filesystem scanning.'
+ );
+ unset( $GLOBALS['emulsify_locator_transient_override'] );
+
+ echo "Component locator smoke checks passed.\n";
+} catch ( Throwable $throwable ) {
+ fwrite( STDERR, $throwable->getMessage() . "\n" );
+ exit( 1 );
+} finally {
+ emulsify_locator_smoke_remove( $work_root );
+}
diff --git a/.github/scripts/core-block-twig-renderer-smoke.php b/.github/scripts/core-block-twig-renderer-smoke.php
new file mode 100644
index 0000000..94cc2b5
--- /dev/null
+++ b/.github/scripts/core-block-twig-renderer-smoke.php
@@ -0,0 +1,450 @@
+%s:%s:%s',
+ $template,
+ $context['block_name'] ?? '',
+ $context['attributes']['className'] ?? ''
+ );
+ }
+ }
+}
+
+namespace {
+ if ( PHP_SAPI !== 'cli' ) {
+ fwrite( STDERR, "This script must be run from the command line.\n" );
+ exit( 1 );
+ }
+
+ $GLOBALS['emulsify_core_block_twig_smoke_filters'] = array();
+ $GLOBALS['emulsify_core_block_twig_smoke_caps'] = array();
+
+ /**
+ * Minimal WP_HTML_Tag_Processor stub for class cleanup coverage.
+ */
+ class WP_HTML_Tag_Processor {
+ /**
+ * HTML being processed.
+ *
+ * @var string
+ */
+ private $html;
+
+ /**
+ * Whether the single smoke fixture tag has been processed.
+ *
+ * @var bool
+ */
+ private $processed = false;
+
+ /**
+ * Constructor.
+ *
+ * @param string $html HTML.
+ */
+ public function __construct( string $html ) {
+ $this->html = $html;
+ }
+
+ /**
+ * Advances to the next tag.
+ *
+ * @return bool TRUE once for the fixture.
+ */
+ public function next_tag(): bool {
+ if ( $this->processed ) {
+ return false;
+ }
+
+ $this->processed = true;
+
+ return true;
+ }
+
+ /**
+ * Removes a CSS class from class attributes.
+ *
+ * @param string $class_name Class name.
+ * @return void
+ */
+ public function remove_class( string $class_name ): void {
+ $this->html = preg_replace_callback(
+ '/class="([^"]*)"/',
+ static function ( array $matches ) use ( $class_name ): string {
+ $classes = array_values(
+ array_filter(
+ preg_split( '/\s+/', trim( $matches[1] ) ),
+ static function ( string $candidate ) use ( $class_name ): bool {
+ return $candidate !== $class_name;
+ }
+ )
+ );
+
+ return empty( $classes ) ? '' : 'class="' . implode( ' ', $classes ) . '"';
+ },
+ $this->html
+ );
+ }
+
+ /**
+ * Gets updated HTML.
+ *
+ * @return string Updated HTML.
+ */
+ public function get_updated_html(): string {
+ return $this->html;
+ }
+ }
+
+ if ( ! function_exists( 'add_filter' ) ) {
+ function add_filter( string $hook, callable $callback, int $priority = 10, int $accepted_args = 1 ): bool {
+ $GLOBALS['emulsify_core_block_twig_smoke_filters'][ $hook ][ $priority ][] = array(
+ 'accepted_args' => $accepted_args,
+ 'callback' => $callback,
+ );
+
+ ksort( $GLOBALS['emulsify_core_block_twig_smoke_filters'][ $hook ] );
+
+ return true;
+ }
+ }
+
+ if ( ! function_exists( 'apply_filters' ) ) {
+ function apply_filters( string $hook, $value, ...$arguments ) {
+ if ( empty( $GLOBALS['emulsify_core_block_twig_smoke_filters'][ $hook ] ) ) {
+ return $value;
+ }
+
+ foreach ( $GLOBALS['emulsify_core_block_twig_smoke_filters'][ $hook ] as $callbacks ) {
+ foreach ( $callbacks as $callback ) {
+ $value = call_user_func_array(
+ $callback['callback'],
+ array_slice(
+ array_merge( array( $value ), $arguments ),
+ 0,
+ $callback['accepted_args']
+ )
+ );
+ }
+ }
+
+ return $value;
+ }
+ }
+
+ if ( ! function_exists( 'get_stylesheet_directory' ) ) {
+ function get_stylesheet_directory(): string {
+ return $GLOBALS['emulsify_core_block_twig_smoke_child'];
+ }
+ }
+
+ if ( ! function_exists( 'get_template_directory' ) ) {
+ function get_template_directory(): string {
+ return $GLOBALS['emulsify_core_block_twig_smoke_parent'];
+ }
+ }
+
+ if ( ! function_exists( 'current_user_can' ) ) {
+ function current_user_can( string $capability ): bool {
+ return in_array( $capability, $GLOBALS['emulsify_core_block_twig_smoke_caps'], true );
+ }
+ }
+
+ if ( ! function_exists( 'sanitize_html_class' ) ) {
+ function sanitize_html_class( string $class_name ): string {
+ return trim( preg_replace( '/[^A-Za-z0-9_-]+/', '-', $class_name ), '-' );
+ }
+ }
+
+ if ( ! function_exists( 'esc_html' ) ) {
+ function esc_html( string $text ): string {
+ return htmlspecialchars( $text, ENT_QUOTES, 'UTF-8' );
+ }
+ }
+
+ if ( ! function_exists( 'esc_html__' ) ) {
+ function esc_html__( string $text, string $domain ): string {
+ unset( $domain );
+
+ return esc_html( $text );
+ }
+ }
+
+ if ( ! function_exists( '__' ) ) {
+ function __( string $text, string $domain ): string {
+ unset( $domain );
+
+ return $text;
+ }
+ }
+
+ if ( ! function_exists( '__return_true' ) ) {
+ function __return_true(): bool {
+ return true;
+ }
+ }
+
+ /**
+ * Fails the smoke script when an assertion is false.
+ *
+ * @param bool $condition Assertion condition.
+ * @param string $message Failure message.
+ * @return void
+ */
+ function emulsify_core_block_twig_smoke_assert( bool $condition, string $message ): void {
+ if ( ! $condition ) {
+ throw new \RuntimeException( $message );
+ }
+ }
+
+ /**
+ * Writes a fixture file.
+ *
+ * @param string $path File path.
+ * @param string $contents File contents.
+ * @return void
+ */
+ function emulsify_core_block_twig_smoke_write( string $path, string $contents ): void {
+ $directory = dirname( $path );
+
+ if ( ! is_dir( $directory ) && ! mkdir( $directory, 0777, true ) ) {
+ throw new \RuntimeException( sprintf( 'Could not create fixture directory: %s', $directory ) );
+ }
+
+ if ( false === file_put_contents( $path, $contents ) ) {
+ throw new \RuntimeException( sprintf( 'Could not write fixture file: %s', $path ) );
+ }
+ }
+
+ /**
+ * Recursively removes a path.
+ *
+ * @param string $path Path to remove.
+ * @return void
+ */
+ function emulsify_core_block_twig_smoke_remove( string $path ): void {
+ if ( ! file_exists( $path ) ) {
+ return;
+ }
+
+ if ( is_file( $path ) || is_link( $path ) ) {
+ unlink( $path );
+ return;
+ }
+
+ $iterator = new \RecursiveIteratorIterator(
+ new \RecursiveDirectoryIterator( $path, \RecursiveDirectoryIterator::SKIP_DOTS ),
+ \RecursiveIteratorIterator::CHILD_FIRST
+ );
+
+ foreach ( $iterator as $item ) {
+ $item->isDir() && ! $item->isLink() ? rmdir( $item->getPathname() ) : unlink( $item->getPathname() );
+ }
+
+ rmdir( $path );
+ }
+
+ $repo_root = dirname( __DIR__, 2 );
+ $work_root = sys_get_temp_dir() . '/emulsify-core-block-twig-' . uniqid( '', true );
+ $child = $work_root . '/child-theme';
+ $parent = $work_root . '/parent-theme';
+
+ $GLOBALS['emulsify_core_block_twig_smoke_child'] = $child;
+ $GLOBALS['emulsify_core_block_twig_smoke_parent'] = $parent;
+
+ try {
+ require_once $repo_root . '/includes/Blocks/CoreBlockTwigRenderer.php';
+
+ emulsify_core_block_twig_smoke_write( $child . '/dist/components/paragraph/paragraph.twig', 'paragraph twig' );
+ emulsify_core_block_twig_smoke_write( $child . '/dist/components/error/throws.twig', 'throws twig' );
+
+ $original = 'Original
';
+ $block = array(
+ 'blockName' => 'core/paragraph',
+ 'attrs' => array(
+ 'className' => 'intro',
+ ),
+ 'innerContent' => array( 'Original' ),
+ 'innerBlocks' => array(),
+ );
+
+ $disabled = new \Emulsify\Theme\Blocks\CoreBlockTwigRenderer();
+ $disabled->register();
+
+ emulsify_core_block_twig_smoke_assert(
+ empty( $GLOBALS['emulsify_core_block_twig_smoke_filters']['render_block'] ),
+ 'Disabled renderer should not hook render_block.'
+ );
+ emulsify_core_block_twig_smoke_assert(
+ $original === apply_filters( 'render_block', $original, $block, null ),
+ 'Disabled renderer should preserve default frontend output.'
+ );
+
+ add_filter( 'emulsify_theme_core_block_twig_rendering_enabled', '__return_true' );
+ add_filter(
+ 'emulsify_theme_core_block_twig_template_map',
+ static function ( array $map ): array {
+ $map['core/paragraph'] = 'dist/components/paragraph/paragraph.twig';
+ $map['core/heading'] = 'dist/components/heading/missing.twig';
+ $map['core/code'] = 'dist/components/error/throws.twig';
+
+ return $map;
+ }
+ );
+ add_filter(
+ 'emulsify_theme_core_block_twig_context',
+ static function ( array $context ): array {
+ $context['smoke_context'] = true;
+
+ return $context;
+ }
+ );
+
+ \Timber\Timber::$context = array(
+ 'site' => 'Smoke Site',
+ );
+
+ $enabled = new \Emulsify\Theme\Blocks\CoreBlockTwigRenderer();
+ $enabled->register();
+
+ emulsify_core_block_twig_smoke_assert(
+ ! empty( $GLOBALS['emulsify_core_block_twig_smoke_filters']['render_block'] ),
+ 'Enabled renderer should hook render_block.'
+ );
+
+ $mapped = apply_filters( 'render_block', $original, $block, null );
+
+ emulsify_core_block_twig_smoke_assert(
+ false !== strpos( $mapped, 'dist/components/paragraph/paragraph.twig:core/paragraph:intro' ),
+ 'Mapped block should render through Timber compile with attributes.'
+ );
+ emulsify_core_block_twig_smoke_assert(
+ true === \Timber\Timber::$compiled[0]['context']['smoke_context'],
+ 'Mapped block context should pass through the context filter.'
+ );
+ emulsify_core_block_twig_smoke_assert(
+ 'Original' === \Timber\Timber::$compiled[0]['context']['inner_content'],
+ 'Mapped block should expose rendered inner content.'
+ );
+
+ $fallback = apply_filters(
+ 'render_block',
+ 'Fallback
',
+ array(
+ 'blockName' => 'core/heading',
+ 'attrs' => array(),
+ ),
+ null
+ );
+
+ emulsify_core_block_twig_smoke_assert(
+ 'Fallback
' === $fallback,
+ 'Mapped blocks with missing templates should preserve original output.'
+ );
+
+ $unmapped = apply_filters(
+ 'render_block',
+ 'Unmapped
',
+ array(
+ 'blockName' => 'core/image',
+ 'attrs' => array(),
+ ),
+ null
+ );
+
+ emulsify_core_block_twig_smoke_assert(
+ 'Unmapped
' === $unmapped,
+ 'Unmapped blocks should preserve original output.'
+ );
+
+ $errored = apply_filters(
+ 'render_block',
+ 'Code
',
+ array(
+ 'blockName' => 'core/code',
+ 'attrs' => array(),
+ ),
+ null
+ );
+
+ emulsify_core_block_twig_smoke_assert(
+ 'Code
' === $errored,
+ 'Template errors should preserve original output for normal visitors.'
+ );
+
+ $GLOBALS['emulsify_core_block_twig_smoke_caps'] = array( 'edit_posts' );
+ $editor_error = apply_filters(
+ 'render_block',
+ 'Code
',
+ array(
+ 'blockName' => 'core/code',
+ 'attrs' => array(),
+ ),
+ null
+ );
+
+ emulsify_core_block_twig_smoke_assert(
+ false !== strpos( $editor_error, 'Emulsify block render error:' ) && false !== strpos( $editor_error, 'Template failed.' ),
+ 'Template errors should expose diagnostics to editors.'
+ );
+
+ add_filter( 'emulsify_theme_core_block_twig_cleanup_classes_enabled', '__return_true' );
+ $cleaned = apply_filters( 'render_block', $original, $block, null );
+
+ emulsify_core_block_twig_smoke_assert(
+ false === strpos( $cleaned, 'wp-block' ) && false !== strpos( $cleaned, 'mapped' ),
+ 'Class cleanup should remove base wp-block classes only when enabled.'
+ );
+
+ echo "Core block Twig renderer smoke checks passed.\n";
+ } finally {
+ emulsify_core_block_twig_smoke_remove( $work_root );
+ }
+}
diff --git a/.github/scripts/docs-command-check.cjs b/.github/scripts/docs-command-check.cjs
new file mode 100644
index 0000000..72b3d3b
--- /dev/null
+++ b/.github/scripts/docs-command-check.cjs
@@ -0,0 +1,409 @@
+#!/usr/bin/env node
+
+const fs = require('fs');
+const path = require('path');
+
+const repoRoot = path.resolve(__dirname, '../..');
+
+// Documentation that describes the repository itself. Each entry is scoped to a
+// heading so unrelated prose in the same file cannot satisfy the check.
+const ROOT_CHECKS = [
+ {
+ relativePath: 'README.md',
+ heading: 'Working inside a generated child theme',
+ packagePath: 'whisk/package.json',
+ packageLabel: 'generated child themes',
+ includeInlineCode: true,
+ expectedScripts: [
+ 'a11y',
+ 'audit',
+ 'audit:twig-stories',
+ 'build',
+ 'develop',
+ 'inspect:components',
+ 'lint',
+ 'storybook',
+ 'storybook-build',
+ 'test',
+ 'vite',
+ ],
+ },
+ {
+ relativePath: 'docs/core-4-vite-workflow.md',
+ heading: 'Core 4, Vite, and Storybook commands',
+ packagePath: 'whisk/package.json',
+ packageLabel: 'generated child themes',
+ includeInlineCode: true,
+ expectedScripts: [
+ 'a11y',
+ 'audit',
+ 'audit:twig-stories',
+ 'build',
+ 'develop',
+ 'inspect:components',
+ 'lint',
+ 'storybook',
+ 'storybook-build',
+ 'test',
+ 'vite',
+ ],
+ },
+ {
+ relativePath: 'UPGRADE.md',
+ heading: 'Component inspector',
+ packagePath: 'whisk/package.json',
+ packageLabel: 'existing generated child themes',
+ includeInlineCode: true,
+ expectedScripts: ['inspect:components'],
+ },
+ {
+ relativePath: 'UPGRADE.md',
+ heading: 'Project audit',
+ packagePath: 'whisk/package.json',
+ packageLabel: 'existing generated child themes',
+ includeInlineCode: true,
+ expectedScripts: ['audit', 'audit:twig-stories'],
+ },
+ {
+ relativePath: 'docs/upgrading-1x-to-2x.md',
+ heading: 'Validation',
+ packagePath: 'package.json',
+ packageLabel: 'the root project',
+ expectedScripts: ['pr:check', 'release:check'],
+ },
+ {
+ relativePath: 'docs/release-process.md',
+ heading: 'Local checks',
+ packagePath: 'package.json',
+ packageLabel: 'the root project',
+ expectedScripts: [
+ 'docs:check-commands',
+ 'lint:php',
+ 'pr:check',
+ 'publish-test',
+ 'release:check',
+ 'test:generated-theme',
+ ],
+ },
+ {
+ relativePath: 'docs/generated-child-theme-contract.md',
+ heading: 'Run the checks',
+ packagePath: 'package.json',
+ packageLabel: 'the root project',
+ expectedScripts: ['test:generated-theme', 'release:check'],
+ },
+];
+
+// Documentation that ships inside a generated child theme. These run twice: once
+// against the Whisk source and once against real generated output, so a command
+// cannot drift between the template and the theme a project actually receives.
+const THEME_DOC_CHECKS = [
+ {
+ relativePath: 'README.md',
+ packagePath: 'package.json',
+ packageLabel: 'the generated child theme',
+ includeInlineCode: true,
+ requireNpmInstall: true,
+ },
+ {
+ relativePath: 'docs/development.md',
+ packagePath: 'package.json',
+ packageLabel: 'the generated child theme',
+ includeInlineCode: true,
+ requireNpmInstall: true,
+ expectedScripts: [
+ 'a11y',
+ 'build',
+ 'develop',
+ 'inspect:components',
+ 'lint',
+ 'storybook',
+ 'storybook-build',
+ 'test',
+ ],
+ },
+ {
+ relativePath: 'docs/upgrading.md',
+ packagePath: 'package.json',
+ packageLabel: 'the generated child theme',
+ includeInlineCode: true,
+ expectedScripts: ['inspect:components'],
+ },
+ {
+ relativePath: 'docs/support-information.md',
+ packagePath: 'package.json',
+ packageLabel: 'the generated child theme',
+ includeInlineCode: true,
+ },
+];
+
+function prefixThemeScope(scope) {
+ return {
+ ...scope,
+ relativePath: path.join('whisk', scope.relativePath),
+ packagePath: path.join('whisk', scope.packagePath),
+ };
+}
+
+function readFile(root, relativePath) {
+ return fs.readFileSync(path.join(root, relativePath), 'utf8');
+}
+
+function readJson(root, relativePath) {
+ return JSON.parse(readFile(root, relativePath));
+}
+
+function normalizeHeadingText(text) {
+ return text.replace(/\s+#+\s*$/, '').trim();
+}
+
+function extractMarkdownSection(root, relativePath, heading) {
+ const contents = readFile(root, relativePath);
+
+ if (!heading) {
+ return { text: contents, startLine: 1 };
+ }
+
+ const lines = contents.split(/\r?\n/);
+
+ for (let index = 0; index < lines.length; index += 1) {
+ const match = lines[index].match(/^(#{1,6})\s+(.+?)\s*$/);
+ if (!match || normalizeHeadingText(match[2]) !== heading) {
+ continue;
+ }
+
+ const level = match[1].length;
+ const bodyStart = index + 1;
+ let bodyEnd = lines.length;
+ for (let nextIndex = bodyStart; nextIndex < lines.length; nextIndex += 1) {
+ const nextMatch = lines[nextIndex].match(/^(#{1,6})\s+/);
+ if (nextMatch && nextMatch[1].length <= level) {
+ bodyEnd = nextIndex;
+ break;
+ }
+ }
+
+ return {
+ text: lines.slice(bodyStart, bodyEnd).join('\n'),
+ startLine: bodyStart + 1,
+ };
+ }
+
+ throw new Error(
+ `${relativePath}:1 is missing the "${heading}" documentation section.`,
+ );
+}
+
+function extractShellFenceCommands(section) {
+ const commands = [];
+ const lines = section.text.split(/\r?\n/);
+ let shellFence = null;
+
+ for (let index = 0; index < lines.length; index += 1) {
+ const line = lines[index];
+ const fenceMatch = line.match(/^```([A-Za-z0-9_-]*)\s*$/);
+
+ if (!shellFence && fenceMatch) {
+ const language = fenceMatch[1].toLowerCase();
+ shellFence = {
+ collect: ['bash', 'sh', 'shell'].includes(language),
+ lines: [],
+ startLine: section.startLine + index + 1,
+ };
+ continue;
+ }
+
+ if (shellFence && /^```\s*$/.test(line)) {
+ if (shellFence.collect) {
+ commands.push(
+ ...extractNpmRunCommands(
+ shellFence.lines.join('\n'),
+ shellFence.startLine,
+ ),
+ );
+ }
+ shellFence = null;
+ continue;
+ }
+
+ if (shellFence && shellFence.collect) {
+ shellFence.lines.push(line);
+ }
+ }
+
+ return commands;
+}
+
+function extractInlineCommands(section) {
+ const commands = [];
+ for (const match of section.text.matchAll(
+ /`([^`\n]*\bnpm\s+run\s+[^`]*)`/g,
+ )) {
+ commands.push(
+ ...extractNpmRunCommands(
+ match[1],
+ section.startLine + lineOffsetForIndex(section.text, match.index),
+ ),
+ );
+ }
+ return commands;
+}
+
+function extractNpmRunCommands(text, startLine) {
+ const commands = [];
+ const lines = text.split(/\r?\n/);
+ for (let index = 0; index < lines.length; index += 1) {
+ const line = lines[index].trim();
+ if (line === '' || line.startsWith('#')) {
+ continue;
+ }
+
+ for (const match of line.matchAll(
+ /\bnpm\s+run\s+([A-Za-z0-9:_-]+)/g,
+ )) {
+ commands.push({
+ script: match[1],
+ line: startLine + index,
+ });
+ }
+ }
+
+ return commands;
+}
+
+function hasExactNpmInstall(section) {
+ return (
+ section.text
+ .split(/\r?\n/)
+ .some((line) => line.trim() === 'npm install') ||
+ /`npm install`/.test(section.text)
+ );
+}
+
+function lineOffsetForIndex(text, index) {
+ return text.slice(0, index).split(/\r?\n/).length - 1;
+}
+
+function unique(values) {
+ return [...new Set(values)];
+}
+
+function validateScope(root, scope, displayRoot) {
+ const section = extractMarkdownSection(root, scope.relativePath, scope.heading);
+ const packageJson = readJson(root, scope.packagePath);
+ const scripts = packageJson.scripts || {};
+ const commands = [
+ ...extractShellFenceCommands(section),
+ ...(scope.includeInlineCode ? extractInlineCommands(section) : []),
+ ];
+ const documentedScripts = unique(
+ commands.map((command) => command.script),
+ ).sort();
+ const displayPath = path.join(displayRoot, scope.relativePath);
+ const scopeLabel = scope.heading ? `${displayPath}#${scope.heading}` : displayPath;
+ const errors = [];
+
+ // A scope with no expected scripts is documentation that may legitimately
+ // describe setup only, so an empty command list is not a failure there.
+ if (scope.expectedScripts?.length && commands.length === 0) {
+ errors.push(
+ `${displayPath}:1 ${scopeLabel} does not document any npm run commands for ${scope.packageLabel}.`,
+ );
+ }
+
+ for (const expectedScript of scope.expectedScripts || []) {
+ if (!documentedScripts.includes(expectedScript)) {
+ errors.push(
+ `${displayPath}:1 should document npm run ${expectedScript} for ${scope.packageLabel}.`,
+ );
+ }
+ }
+
+ if (scope.requireNpmInstall && !hasExactNpmInstall(section)) {
+ errors.push(
+ `${displayPath}:1 should document the exact npm install command for ${scope.packageLabel}.`,
+ );
+ }
+
+ for (const command of commands) {
+ if (!scripts[command.script]) {
+ errors.push(
+ `${displayPath}:${command.line} documents npm run ${command.script} for ${scope.packageLabel}, but ${path.join(displayRoot, scope.packagePath)} has no "${command.script}" script.`,
+ );
+ }
+ }
+
+ return {
+ documentedScripts,
+ errors,
+ label: `${scopeLabel} -> ${path.join(displayRoot, scope.packagePath)}`,
+ };
+}
+
+/**
+ * Validates documented npm commands against the package that exposes them.
+ *
+ * With no options this checks the repository's own documentation plus the Whisk
+ * source templates. With `generatedTheme` it checks a real generated child theme
+ * directory, so generated output is held to the same contract as the template.
+ */
+function validateDocumentation(options = {}) {
+ const generatedTheme = options.generatedTheme || null;
+ const root = generatedTheme ? path.resolve(generatedTheme) : repoRoot;
+ const displayRoot = generatedTheme ? path.basename(root) : '';
+ const checks = generatedTheme
+ ? THEME_DOC_CHECKS
+ : [...ROOT_CHECKS, ...THEME_DOC_CHECKS.map(prefixThemeScope)];
+
+ const errors = [];
+ const summaries = [];
+
+ for (const scope of checks) {
+ let result;
+
+ try {
+ result = validateScope(root, scope, displayRoot);
+ } catch (error) {
+ errors.push(error.message);
+ continue;
+ }
+
+ errors.push(...result.errors);
+ summaries.push(`${result.label}: ${result.documentedScripts.join(', ')}`);
+ }
+
+ return { errors, summaries, count: checks.length };
+}
+
+function parseArgs(argv) {
+ const options = {};
+
+ for (let index = 0; index < argv.length; index += 1) {
+ if (argv[index] === '--generated-theme') {
+ options.generatedTheme = argv[index + 1];
+ index += 1;
+ }
+ }
+
+ return options;
+}
+
+if (require.main === module) {
+ const result = validateDocumentation(parseArgs(process.argv.slice(2)));
+
+ if (result.errors.length > 0) {
+ for (const error of result.errors) {
+ console.error(error);
+ }
+ process.exit(1);
+ }
+
+ console.log(
+ `Validated documented npm scripts in ${result.count} documentation sections.`,
+ );
+ for (const summary of result.summaries) {
+ console.log(`- ${summary}`);
+ }
+}
+
+module.exports = { validateDocumentation };
diff --git a/.github/scripts/editor-enhancements-smoke.php b/.github/scripts/editor-enhancements-smoke.php
new file mode 100644
index 0000000..ca89421
--- /dev/null
+++ b/.github/scripts/editor-enhancements-smoke.php
@@ -0,0 +1,352 @@
+ $accepted_args,
+ 'callback' => $callback,
+ );
+
+ ksort( $GLOBALS['emulsify_editor_enhancements_smoke_hooks'][ $hook ] );
+
+ return true;
+ }
+}
+
+if ( ! function_exists( 'add_action' ) ) {
+ function add_action( string $hook, callable $callback, int $priority = 10, int $accepted_args = 1 ): bool {
+ return add_filter( $hook, $callback, $priority, $accepted_args );
+ }
+}
+
+if ( ! function_exists( 'apply_filters' ) ) {
+ function apply_filters( string $hook, $value, ...$arguments ) {
+ if ( empty( $GLOBALS['emulsify_editor_enhancements_smoke_hooks'][ $hook ] ) ) {
+ return $value;
+ }
+
+ foreach ( $GLOBALS['emulsify_editor_enhancements_smoke_hooks'][ $hook ] as $callbacks ) {
+ foreach ( $callbacks as $callback ) {
+ $value = call_user_func_array(
+ $callback['callback'],
+ array_slice(
+ array_merge( array( $value ), $arguments ),
+ 0,
+ $callback['accepted_args']
+ )
+ );
+ }
+ }
+
+ return $value;
+ }
+}
+
+if ( ! function_exists( 'do_action' ) ) {
+ function do_action( string $hook, ...$arguments ): void {
+ if ( empty( $GLOBALS['emulsify_editor_enhancements_smoke_hooks'][ $hook ] ) ) {
+ return;
+ }
+
+ foreach ( $GLOBALS['emulsify_editor_enhancements_smoke_hooks'][ $hook ] as $callbacks ) {
+ foreach ( $callbacks as $callback ) {
+ call_user_func_array(
+ $callback['callback'],
+ array_slice( $arguments, 0, $callback['accepted_args'] )
+ );
+ }
+ }
+ }
+}
+
+if ( ! function_exists( 'get_stylesheet_directory' ) ) {
+ function get_stylesheet_directory(): string {
+ return $GLOBALS['emulsify_editor_enhancements_smoke_child'];
+ }
+}
+
+if ( ! function_exists( 'get_template_directory' ) ) {
+ function get_template_directory(): string {
+ return $GLOBALS['emulsify_editor_enhancements_smoke_parent'];
+ }
+}
+
+if ( ! function_exists( 'get_stylesheet_directory_uri' ) ) {
+ function get_stylesheet_directory_uri(): string {
+ return 'https://example.test/child';
+ }
+}
+
+if ( ! function_exists( 'get_template_directory_uri' ) ) {
+ function get_template_directory_uri(): string {
+ return 'https://example.test/parent';
+ }
+}
+
+if ( ! function_exists( 'wp_enqueue_style' ) ) {
+ function wp_enqueue_style( string $handle, string $src, array $deps = array(), $ver = false ): void {
+ $GLOBALS['emulsify_editor_enhancements_smoke_styles'][] = compact( 'handle', 'src', 'deps', 'ver' );
+ }
+}
+
+if ( ! function_exists( 'wp_enqueue_script' ) ) {
+ function wp_enqueue_script( string $handle, string $src, array $deps = array(), $ver = false, $args = array() ): void {
+ $GLOBALS['emulsify_editor_enhancements_smoke_scripts'][] = compact( 'handle', 'src', 'deps', 'ver', 'args' );
+ }
+}
+
+if ( ! function_exists( 'wp_add_inline_script' ) ) {
+ function wp_add_inline_script( string $handle, string $data, string $position = 'after' ): bool {
+ $GLOBALS['emulsify_editor_enhancements_smoke_inline'][] = compact( 'handle', 'data', 'position' );
+
+ return true;
+ }
+}
+
+if ( ! function_exists( 'wp_json_encode' ) ) {
+ function wp_json_encode( $value ) {
+ return json_encode( $value );
+ }
+}
+
+if ( ! function_exists( 'sanitize_key' ) ) {
+ function sanitize_key( string $key ): string {
+ return trim( preg_replace( '/[^a-z0-9_-]+/', '-', strtolower( $key ) ), '-' );
+ }
+}
+
+if ( ! function_exists( 'wp_get_attachment_caption' ) ) {
+ function wp_get_attachment_caption( int $attachment_id ) {
+ return $GLOBALS['emulsify_editor_enhancements_smoke_captions'][ $attachment_id ] ?? '';
+ }
+}
+
+if ( ! function_exists( 'esc_attr' ) ) {
+ function esc_attr( string $value ): string {
+ return htmlspecialchars( $value, ENT_QUOTES, 'UTF-8' );
+ }
+}
+
+if ( ! function_exists( 'esc_html' ) ) {
+ function esc_html( string $value ): string {
+ return htmlspecialchars( $value, ENT_QUOTES, 'UTF-8' );
+ }
+}
+
+/**
+ * Fails the smoke script when an assertion is false.
+ *
+ * @param bool $condition Assertion condition.
+ * @param string $message Failure message.
+ * @return void
+ */
+function emulsify_editor_enhancements_smoke_assert( bool $condition, string $message ): void {
+ if ( ! $condition ) {
+ throw new RuntimeException( $message );
+ }
+}
+
+/**
+ * Writes a fixture file.
+ *
+ * @param string $path File path.
+ * @param string $contents File contents.
+ * @return void
+ */
+function emulsify_editor_enhancements_smoke_write( string $path, string $contents ): void {
+ $directory = dirname( $path );
+
+ if ( ! is_dir( $directory ) && ! mkdir( $directory, 0777, true ) ) {
+ throw new RuntimeException( sprintf( 'Could not create fixture directory: %s', $directory ) );
+ }
+
+ if ( false === file_put_contents( $path, $contents ) ) {
+ throw new RuntimeException( sprintf( 'Could not write fixture file: %s', $path ) );
+ }
+}
+
+/**
+ * Recursively removes a path.
+ *
+ * @param string $path Path to remove.
+ * @return void
+ */
+function emulsify_editor_enhancements_smoke_remove( string $path ): void {
+ if ( ! file_exists( $path ) ) {
+ return;
+ }
+
+ if ( is_file( $path ) || is_link( $path ) ) {
+ unlink( $path );
+ return;
+ }
+
+ $iterator = new RecursiveIteratorIterator(
+ new RecursiveDirectoryIterator( $path, RecursiveDirectoryIterator::SKIP_DOTS ),
+ RecursiveIteratorIterator::CHILD_FIRST
+ );
+
+ foreach ( $iterator as $item ) {
+ $item->isDir() && ! $item->isLink() ? rmdir( $item->getPathname() ) : unlink( $item->getPathname() );
+ }
+
+ rmdir( $path );
+}
+
+$repo_root = dirname( __DIR__, 2 );
+$work_root = sys_get_temp_dir() . '/emulsify-editor-enhancements-' . uniqid( '', true );
+$child = $work_root . '/child-theme';
+$parent = $work_root . '/parent-theme';
+
+$GLOBALS['emulsify_editor_enhancements_smoke_child'] = $child;
+$GLOBALS['emulsify_editor_enhancements_smoke_parent'] = $parent;
+
+try {
+ require_once $repo_root . '/includes/Support/FileDiscovery.php';
+ require_once $repo_root . '/includes/Support/AssetRecord.php';
+ require_once $repo_root . '/includes/Support/AssetEnqueuer.php';
+ require_once $repo_root . '/includes/Support/AssetManifest.php';
+ require_once $repo_root . '/includes/Editor/Enhancements.php';
+
+ $service = new Emulsify\Theme\Editor\Enhancements();
+ $service->register();
+
+ foreach ( array( 'enqueue_block_editor_assets', 'render_block_data', 'render_block' ) as $hook ) {
+ emulsify_editor_enhancements_smoke_assert(
+ isset( $GLOBALS['emulsify_editor_enhancements_smoke_hooks'][ $hook ] ),
+ sprintf( 'Editor enhancements should register the %s hook.', $hook )
+ );
+ }
+
+ $file_block = array(
+ 'blockName' => 'core/file',
+ 'attrs' => array(
+ 'id' => 42,
+ 'emulsifyShowMediaCaption' => true,
+ 'className' => 'existing-class',
+ ),
+ );
+ $content = '';
+
+ emulsify_editor_enhancements_smoke_assert(
+ $content === apply_filters( 'render_block', $content, $file_block, null ),
+ 'File captions should be no-op by default.'
+ );
+
+ foreach ( array( 'enqueue_block_editor_assets', 'render_block_data', 'render_block' ) as $hook ) {
+ unset( $GLOBALS['emulsify_editor_enhancements_smoke_hooks'][ $hook ] );
+ }
+
+ add_filter(
+ 'emulsify_theme_editor_enhancements_config',
+ static function ( array $config ): array {
+ $config['columnsEqualHeight']['enabled'] = true;
+ $config['fileCaption']['enabled'] = true;
+ $config['embedVariations']['enabled'] = true;
+ $config['placement'] = array(
+ 'enabled' => true,
+ 'blocks' => array( 'acf/example-hero' ),
+ 'singleton' => true,
+ 'requireTop' => true,
+ );
+
+ return $config;
+ }
+ );
+
+ $service = new Emulsify\Theme\Editor\Enhancements();
+ $service->register();
+
+ $asset_directories_seen = false;
+ $asset_files_seen = false;
+
+ add_filter(
+ 'emulsify_theme_editor_asset_directories',
+ static function ( array $directories, string $directory ) use ( &$asset_directories_seen ): array {
+ $asset_directories_seen = $asset_directories_seen || 'dist/global/editor' === $directory;
+
+ return $directories;
+ },
+ 10,
+ 2
+ );
+
+ add_filter(
+ 'emulsify_theme_editor_asset_files',
+ static function ( array $assets, string $directory ) use ( &$asset_files_seen ): array {
+ $asset_files_seen = $asset_files_seen || 'dist/global/editor' === $directory;
+
+ return $assets;
+ },
+ 10,
+ 2
+ );
+
+ $parsed = apply_filters( 'render_block_data', $file_block, $file_block, null );
+
+ emulsify_editor_enhancements_smoke_assert(
+ false !== strpos( $parsed['attrs']['className'], 'existing-class' ) && false !== strpos( $parsed['attrs']['className'], 'has-media-caption' ),
+ 'File caption render_block_data should append the configured class.'
+ );
+
+ $GLOBALS['emulsify_editor_enhancements_smoke_captions'][42] = 'Media caption';
+ $rendered = apply_filters( 'render_block', $content, $file_block, null );
+
+ emulsify_editor_enhancements_smoke_assert(
+ false !== strpos( $rendered, 'Media caption
' ),
+ 'File caption render_block should append the attachment caption.'
+ );
+
+ emulsify_editor_enhancements_smoke_write( $child . '/dist/global/editor/js/index.js', 'window.editorSmoke = true;' );
+ emulsify_editor_enhancements_smoke_write( $child . '/dist/global/editor/css/index.css', '.editor-smoke{}' );
+ emulsify_editor_enhancements_smoke_write( $parent . '/dist/global/editor/js/index.js', 'window.parentEditorSmoke = true;' );
+
+ do_action( 'enqueue_block_editor_assets' );
+
+ emulsify_editor_enhancements_smoke_assert(
+ $asset_directories_seen,
+ 'emulsify_theme_editor_asset_directories should run during editor asset discovery.'
+ );
+ emulsify_editor_enhancements_smoke_assert(
+ $asset_files_seen,
+ 'emulsify_theme_editor_asset_files should run during editor asset discovery.'
+ );
+ emulsify_editor_enhancements_smoke_assert(
+ 1 === count( $GLOBALS['emulsify_editor_enhancements_smoke_styles'] ),
+ 'Editor asset enqueue should include the child editor stylesheet.'
+ );
+ emulsify_editor_enhancements_smoke_assert(
+ 1 === count( $GLOBALS['emulsify_editor_enhancements_smoke_scripts'] ),
+ 'Editor asset enqueue should include the child editor script and skip duplicate parent relative paths.'
+ );
+ emulsify_editor_enhancements_smoke_assert(
+ in_array( 'wp-blocks', $GLOBALS['emulsify_editor_enhancements_smoke_scripts'][0]['deps'], true ),
+ 'Editor script should declare WordPress editor dependencies.'
+ );
+ emulsify_editor_enhancements_smoke_assert(
+ ! empty( $GLOBALS['emulsify_editor_enhancements_smoke_inline'] )
+ && false !== strpos( $GLOBALS['emulsify_editor_enhancements_smoke_inline'][0]['data'], 'emulsifyEditorEnhancements' )
+ && false !== strpos( $GLOBALS['emulsify_editor_enhancements_smoke_inline'][0]['data'], '"fileCaption":{"enabled":true' ),
+ 'Editor script should receive the filtered enhancement config.'
+ );
+
+ echo "Editor enhancements smoke checks passed.\n";
+} finally {
+ emulsify_editor_enhancements_smoke_remove( $work_root );
+}
diff --git a/.github/scripts/editor-policy-smoke.php b/.github/scripts/editor-policy-smoke.php
new file mode 100644
index 0000000..3ba0946
--- /dev/null
+++ b/.github/scripts/editor-policy-smoke.php
@@ -0,0 +1,380 @@
+ $accepted_args,
+ 'callback' => $callback,
+ );
+
+ ksort( $GLOBALS['emulsify_editor_policy_smoke_filters'][ $hook ] );
+
+ return true;
+ }
+}
+
+if ( ! function_exists( 'apply_filters' ) ) {
+ function apply_filters( string $hook, $value, ...$arguments ) {
+ if ( empty( $GLOBALS['emulsify_editor_policy_smoke_filters'][ $hook ] ) ) {
+ return $value;
+ }
+
+ foreach ( $GLOBALS['emulsify_editor_policy_smoke_filters'][ $hook ] as $callbacks ) {
+ foreach ( $callbacks as $callback ) {
+ $value = call_user_func_array(
+ $callback['callback'],
+ array_slice(
+ array_merge( array( $value ), $arguments ),
+ 0,
+ $callback['accepted_args']
+ )
+ );
+ }
+ }
+
+ return $value;
+ }
+}
+
+if ( ! function_exists( 'get_stylesheet_directory' ) ) {
+ function get_stylesheet_directory(): string {
+ return $GLOBALS['emulsify_editor_policy_smoke_child'];
+ }
+}
+
+if ( ! function_exists( 'get_template_directory' ) ) {
+ function get_template_directory(): string {
+ return $GLOBALS['emulsify_editor_policy_smoke_parent'];
+ }
+}
+
+if ( ! function_exists( 'get_post_type' ) ) {
+ function get_post_type( $post ): string {
+ return is_object( $post ) && isset( $post->post_type ) ? (string) $post->post_type : '';
+ }
+}
+
+if ( ! function_exists( 'current_user_can' ) ) {
+ function current_user_can( string $capability ): bool {
+ return in_array( $capability, $GLOBALS['emulsify_editor_policy_smoke_current_caps'], true );
+ }
+}
+
+/**
+ * Fails the smoke script when an assertion is false.
+ *
+ * @param bool $condition Assertion condition.
+ * @param string $message Failure message.
+ * @return void
+ */
+function emulsify_editor_policy_smoke_assert( bool $condition, string $message ): void {
+ if ( ! $condition ) {
+ throw new RuntimeException( $message );
+ }
+}
+
+/**
+ * Writes a fixture file.
+ *
+ * @param string $path File path.
+ * @param string $contents File contents.
+ * @return void
+ */
+function emulsify_editor_policy_smoke_write( string $path, string $contents ): void {
+ $directory = dirname( $path );
+
+ if ( ! is_dir( $directory ) && ! mkdir( $directory, 0777, true ) ) {
+ throw new RuntimeException( sprintf( 'Could not create fixture directory: %s', $directory ) );
+ }
+
+ if ( false === file_put_contents( $path, $contents ) ) {
+ throw new RuntimeException( sprintf( 'Could not write fixture file: %s', $path ) );
+ }
+}
+
+/**
+ * Recursively removes a path.
+ *
+ * @param string $path Path to remove.
+ * @return void
+ */
+function emulsify_editor_policy_smoke_remove( string $path ): void {
+ if ( ! file_exists( $path ) ) {
+ return;
+ }
+
+ if ( is_file( $path ) || is_link( $path ) ) {
+ unlink( $path );
+ return;
+ }
+
+ $iterator = new RecursiveIteratorIterator(
+ new RecursiveDirectoryIterator( $path, RecursiveDirectoryIterator::SKIP_DOTS ),
+ RecursiveIteratorIterator::CHILD_FIRST
+ );
+
+ foreach ( $iterator as $item ) {
+ $item->isDir() && ! $item->isLink() ? rmdir( $item->getPathname() ) : unlink( $item->getPathname() );
+ }
+
+ rmdir( $path );
+}
+
+$repo_root = dirname( __DIR__, 2 );
+$work_root = sys_get_temp_dir() . '/emulsify-editor-policy-' . uniqid( '', true );
+$child = $work_root . '/child-theme';
+$parent = $work_root . '/parent-theme';
+$context = (object) array(
+ 'post' => (object) array(
+ 'post_type' => 'page',
+ ),
+);
+
+$GLOBALS['emulsify_editor_policy_smoke_child'] = $child;
+$GLOBALS['emulsify_editor_policy_smoke_parent'] = $parent;
+
+try {
+ require_once $repo_root . '/includes/Editor/BlockNames.php';
+ require_once $repo_root . '/includes/Editor/PolicyOptions.php';
+ require_once $repo_root . '/includes/Editor/PatternGovernance.php';
+ require_once $repo_root . '/includes/Editor/AllowedBlockTypes.php';
+ require_once $repo_root . '/includes/Editor/UserPatternPermissions.php';
+ require_once $repo_root . '/includes/Editor/BlockSupportOverrides.php';
+ require_once $repo_root . '/includes/Editor/Policy.php';
+
+ $policy = new Emulsify\Theme\Editor\Policy();
+ $policy->register();
+
+ foreach ( array( 'allowed_block_types_all', 'block_editor_settings_all', 'register_post_type_args', 'block_type_metadata_settings', 'register_block_type_args' ) as $hook ) {
+ emulsify_editor_policy_smoke_assert(
+ isset( $GLOBALS['emulsify_editor_policy_smoke_filters'][ $hook ] ),
+ sprintf( 'Editor policy should register the %s hook.', $hook )
+ );
+ }
+
+ emulsify_editor_policy_smoke_assert(
+ true === apply_filters( 'allowed_block_types_all', true, $context ),
+ 'Editor policy should preserve default allowed block behavior when unconfigured.'
+ );
+
+ $default_settings = array(
+ 'blockPatterns' => array(
+ array(
+ 'name' => 'core/query',
+ ),
+ ),
+ 'enableUserPatterns' => true,
+ );
+
+ emulsify_editor_policy_smoke_assert(
+ $default_settings === apply_filters( 'block_editor_settings_all', $default_settings, $context ),
+ 'Editor policy should preserve editor settings when unconfigured.'
+ );
+
+ emulsify_editor_policy_smoke_assert(
+ array( 'capabilities' => array( 'create_posts' => 'edit_posts' ) ) === apply_filters(
+ 'register_post_type_args',
+ array(
+ 'capabilities' => array(
+ 'create_posts' => 'edit_posts',
+ ),
+ ),
+ 'wp_block'
+ ),
+ 'Editor policy should preserve wp_block capabilities when unconfigured.'
+ );
+
+ emulsify_editor_policy_smoke_write(
+ $child . '/patterns/hero.json',
+ (string) json_encode(
+ array(
+ 'content' => 'Intro
',
+ )
+ )
+ );
+
+ add_filter(
+ 'emulsify_theme_editor_policy_options',
+ static function ( array $options ) use ( $child ): array {
+ $options['allowed_block_types'] = array(
+ 'default' => array( 'core/paragraph', 'heading' ),
+ 'by_post_type' => array(
+ 'page' => array( 'core/image' ),
+ ),
+ );
+ $options['auto_allow_pattern_blocks'] = true;
+ $options['pattern_directories'] = array( $child . '/patterns' );
+ $options['pattern_namespaces'] = array( 'project', 'theme' );
+ $options['disable_user_patterns_for_non_admins'] = true;
+ $options['admin_capability'] = 'manage_options';
+ $options['restrict_wp_block_creation'] = true;
+ $options['wp_block_create_capability'] = 'manage_options';
+ $options['block_support_overrides'] = array(
+ '*' => array(
+ 'supports' => array(
+ 'styles' => false,
+ ),
+ ),
+ 'core/button' => array(
+ 'styles' => array(),
+ 'supports' => array(
+ 'color' => array(
+ 'gradients' => false,
+ ),
+ ),
+ ),
+ );
+
+ return $options;
+ }
+ );
+
+ add_filter(
+ 'emulsify_theme_allowed_block_types',
+ static function ( ?array $blocks, string $post_type ): ?array {
+ if ( 'page' === $post_type && is_array( $blocks ) ) {
+ $blocks[] = 'core/quote';
+ }
+
+ return $blocks;
+ },
+ 10,
+ 2
+ );
+
+ add_filter(
+ 'emulsify_theme_pattern_namespaces',
+ static function ( array $namespaces ): array {
+ $namespaces[] = 'child';
+
+ return $namespaces;
+ }
+ );
+
+ add_filter(
+ 'emulsify_theme_block_support_overrides',
+ static function ( array $overrides, string $block_name, array $settings, string $source ): array {
+ if ( 'core/button' === $block_name && 'register_block_type_args' === $source ) {
+ $overrides['core/button']['supports']['anchor'] = false;
+ }
+
+ return $overrides;
+ },
+ 10,
+ 4
+ );
+
+ $allowed = apply_filters( 'allowed_block_types_all', array( 'core/html' ), $context );
+
+ foreach ( array( 'core/html', 'core/paragraph', 'core/heading', 'core/image', 'core/quote', 'acf/project-hero' ) as $expected_block ) {
+ emulsify_editor_policy_smoke_assert(
+ in_array( $expected_block, $allowed, true ),
+ sprintf( 'Allowed block policy should include %s.', $expected_block )
+ );
+ }
+
+ emulsify_editor_policy_smoke_assert(
+ 1 === count( array_keys( $allowed, 'core/paragraph', true ) ),
+ 'Allowed block policy should deduplicate configured and pattern-derived block names.'
+ );
+
+ $filtered_settings = apply_filters(
+ 'block_editor_settings_all',
+ array(
+ 'blockPatterns' => array(
+ array(
+ 'name' => 'project/hero',
+ ),
+ array(
+ 'name' => 'theme/card',
+ ),
+ array(
+ 'name' => 'child/banner',
+ ),
+ array(
+ 'name' => 'core/query',
+ ),
+ ),
+ 'enableUserPatterns' => true,
+ ),
+ $context
+ );
+ $pattern_names = array_column( $filtered_settings['blockPatterns'], 'name' );
+
+ emulsify_editor_policy_smoke_assert(
+ array( 'project/hero', 'theme/card', 'child/banner' ) === $pattern_names,
+ 'Pattern namespace policy should keep only configured namespaces.'
+ );
+ emulsify_editor_policy_smoke_assert(
+ false === $filtered_settings['enableUserPatterns'],
+ 'Editor policy should disable user-created patterns for users without the admin capability.'
+ );
+
+ $wp_block_args = apply_filters( 'register_post_type_args', array( 'capabilities' => array( 'edit_posts' => 'edit_posts' ) ), 'wp_block' );
+
+ emulsify_editor_policy_smoke_assert(
+ true === $wp_block_args['map_meta_cap'] && 'manage_options' === $wp_block_args['capabilities']['create_posts'],
+ 'Editor policy should restrict wp_block creation to the configured capability.'
+ );
+ emulsify_editor_policy_smoke_assert(
+ array() === apply_filters( 'register_post_type_args', array(), 'post' ),
+ 'Editor policy should leave non-wp_block post type arguments alone.'
+ );
+
+ $metadata_settings = apply_filters(
+ 'block_type_metadata_settings',
+ array(
+ 'supports' => array(
+ 'spacing' => true,
+ ),
+ ),
+ array(
+ 'name' => 'core/button',
+ )
+ );
+
+ emulsify_editor_policy_smoke_assert(
+ false === $metadata_settings['supports']['styles']
+ && true === $metadata_settings['supports']['spacing']
+ && false === $metadata_settings['supports']['color']['gradients']
+ && array() === $metadata_settings['styles'],
+ 'Block support overrides should apply to metadata settings without dropping existing supports.'
+ );
+
+ $registration_args = apply_filters(
+ 'register_block_type_args',
+ array(
+ 'supports' => array(
+ 'anchor' => true,
+ ),
+ ),
+ 'core/button'
+ );
+
+ emulsify_editor_policy_smoke_assert(
+ false === $registration_args['supports']['styles']
+ && false === $registration_args['supports']['anchor']
+ && false === $registration_args['supports']['color']['gradients'],
+ 'Block support overrides should apply to register_block_type_args and honor source-aware filters.'
+ );
+
+ echo "Editor policy smoke checks passed.\n";
+} catch ( Throwable $throwable ) {
+ fwrite( STDERR, $throwable->getMessage() . "\n" );
+ exit( 1 );
+} finally {
+ emulsify_editor_policy_smoke_remove( $work_root );
+}
diff --git a/.github/scripts/generated-theme-contract.cjs b/.github/scripts/generated-theme-contract.cjs
new file mode 100644
index 0000000..f911bd7
--- /dev/null
+++ b/.github/scripts/generated-theme-contract.cjs
@@ -0,0 +1,760 @@
+#!/usr/bin/env node
+
+/**
+ * Validates a generated Emulsify WordPress child theme against the generation
+ * contract documented in docs/generated-child-theme-contract.md.
+ *
+ * Usage:
+ * generated-theme-contract.cjs [--check-built-assets] \
+ *
+ *
+ * The validator is deliberately independent of the generator implementations so
+ * the WP-CLI path and the standalone starter path are held to the same contract.
+ */
+
+const fs = require('fs');
+const path = require('path');
+const { isDeepStrictEqual } = require('util');
+
+const { validateDocumentation } = require('./docs-command-check.cjs');
+
+const SECTION_LABELS = {
+ generation: 'generation',
+ wordpress: 'WordPress metadata',
+ frontend: 'frontend metadata',
+ references: 'file references',
+ documentation: 'documentation',
+ placeholders: 'placeholder replacement',
+ build: 'build',
+};
+
+const REQUIRED_DOCUMENTED_SCRIPTS = [
+ 'a11y',
+ 'build',
+ 'develop',
+ 'inspect:components',
+ 'lint',
+ 'storybook',
+ 'storybook-build',
+ 'test',
+];
+
+const REQUIRED_GENERATED_FILES = [
+ 'style.css',
+ 'functions.php',
+ 'package.json',
+ 'project.emulsify.json',
+ 'README.md',
+ 'docs/development.md',
+ 'docs/support-information.md',
+ 'docs/upgrading.md',
+ 'templates/page.twig',
+];
+
+// Generation-only tooling and build output must never reach a project repo.
+const FORBIDDEN_GENERATED_PATHS = ['.cli', 'node_modules', 'dist', '.out', '.coverage'];
+
+const IGNORED_SCAN_DIRECTORIES = new Set([
+ 'node_modules',
+ 'dist',
+ '.out',
+ '.coverage',
+ '.git',
+]);
+
+const DOCUMENTATION_TOKEN_PATTERN = /%%EMULSIFY_[A-Z_]+%%/;
+
+function normalizeWhitespace(value) {
+ return String(value || '').replace(/\s+/g, ' ').trim();
+}
+
+function escapeRegExp(value) {
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+}
+
+function expected(value) {
+ return JSON.stringify(value);
+}
+
+function readTextFile(filePath) {
+ return fs.readFileSync(filePath, 'utf8');
+}
+
+function readJsonFile(filePath) {
+ return JSON.parse(readTextFile(filePath));
+}
+
+function readThemeHeaders(filePath) {
+ const headers = {};
+
+ for (const line of readTextFile(filePath).split(/\r?\n/)) {
+ const match = line.match(/^\s*\*?\s*([A-Za-z][A-Za-z ]*?):\s*(.+?)\s*$/);
+ if (match) {
+ headers[match[1].trim()] = match[2].trim();
+ }
+ }
+
+ return headers;
+}
+
+function listFilesRecursive(root, relative = '') {
+ const absolute = path.join(root, relative);
+ const results = [];
+
+ for (const entry of fs.readdirSync(absolute, { withFileTypes: true })) {
+ const entryRelative = relative ? path.join(relative, entry.name) : entry.name;
+
+ if (entry.isSymbolicLink()) {
+ results.push({ relativePath: entryRelative, symlink: true });
+ continue;
+ }
+
+ if (entry.isDirectory()) {
+ if (IGNORED_SCAN_DIRECTORIES.has(entry.name)) {
+ results.push({ relativePath: entryRelative, directory: true, ignored: true });
+ continue;
+ }
+ results.push({ relativePath: entryRelative, directory: true });
+ results.push(...listFilesRecursive(root, entryRelative));
+ continue;
+ }
+
+ results.push({ relativePath: entryRelative, file: true });
+ }
+
+ return results;
+}
+
+function isBinary(buffer) {
+ return buffer.includes(0);
+}
+
+function createValidator() {
+ const sections = new Map(
+ Object.keys(SECTION_LABELS).map((key) => [key, []]),
+ );
+ const skipped = new Set();
+
+ return {
+ addError(section, message) {
+ sections.get(section).push(message);
+ },
+ skip(section) {
+ skipped.add(section);
+ },
+ get errors() {
+ return [...sections.values()].flat();
+ },
+ format() {
+ const lines = [];
+
+ for (const [key, label] of Object.entries(SECTION_LABELS)) {
+ if (skipped.has(key)) {
+ lines.push(`SKIP ${label}`);
+ continue;
+ }
+
+ const messages = sections.get(key);
+ if (messages.length === 0) {
+ lines.push(`PASS ${label}`);
+ continue;
+ }
+
+ lines.push(`FAIL ${label}`);
+ for (const message of messages) {
+ lines.push(` ${message}`);
+ }
+ }
+
+ return lines.join('\n');
+ },
+ };
+}
+
+function checkEqual(addError, section, themeLabel, file, key, actual, want) {
+ if (actual !== want) {
+ addError(
+ section,
+ `${themeLabel} has ${key} ${expected(actual)} in ${expected(file)}; expected ${expected(want)}.`,
+ );
+ }
+}
+
+function validateGenerationShape(validator, contract) {
+ const { themeDir, themeLabel, sourceDir } = contract;
+ const entries = listFilesRecursive(themeDir);
+ const relativeFiles = new Set(
+ entries.filter((entry) => entry.file).map((entry) => entry.relativePath),
+ );
+
+ for (const requiredFile of REQUIRED_GENERATED_FILES) {
+ if (
+ fs.existsSync(path.join(sourceDir, requiredFile)) &&
+ !relativeFiles.has(requiredFile)
+ ) {
+ validator.addError(
+ 'generation',
+ `${themeLabel} is missing required generated file ${expected(requiredFile)}.`,
+ );
+ }
+ }
+
+ for (const entry of entries) {
+ if (entry.symlink) {
+ validator.addError(
+ 'generation',
+ `${themeLabel} contains symbolic link ${expected(entry.relativePath)}; generated files must be self-contained.`,
+ );
+ }
+ }
+
+ for (const forbidden of FORBIDDEN_GENERATED_PATHS) {
+ if (fs.existsSync(path.join(themeDir, forbidden))) {
+ validator.addError(
+ 'generation',
+ `${themeLabel} retained generation-only or build path ${expected(forbidden)}; expected it to be omitted.`,
+ );
+ }
+ }
+
+ return { entries, relativeFiles };
+}
+
+function validateWordPressMetadata(validator, contract) {
+ const { themeDir, themeLabel, machineName, sourceLabel, description, parent, sourceDir } =
+ contract;
+ const stylePath = path.join(themeDir, 'style.css');
+
+ if (!fs.existsSync(stylePath)) {
+ validator.addError('wordpress', `${themeLabel} is missing "style.css".`);
+ return;
+ }
+
+ const headers = readThemeHeaders(stylePath);
+ const sourceHeaders = readThemeHeaders(path.join(sourceDir, 'style.css'));
+
+ checkEqual(
+ validator.addError,
+ 'wordpress',
+ themeLabel,
+ 'style.css',
+ 'Theme Name',
+ headers['Theme Name'],
+ sourceLabel,
+ );
+ checkEqual(
+ validator.addError,
+ 'wordpress',
+ themeLabel,
+ 'style.css',
+ 'Text Domain',
+ headers['Text Domain'],
+ machineName,
+ );
+ checkEqual(
+ validator.addError,
+ 'wordpress',
+ themeLabel,
+ 'style.css',
+ 'Template',
+ headers.Template,
+ parent,
+ );
+ checkEqual(
+ validator.addError,
+ 'wordpress',
+ themeLabel,
+ 'style.css',
+ 'Description',
+ headers.Description,
+ description,
+ );
+ checkEqual(
+ validator.addError,
+ 'wordpress',
+ themeLabel,
+ 'style.css',
+ 'License',
+ headers.License,
+ sourceHeaders.License,
+ );
+
+ if (!headers.Version) {
+ validator.addError(
+ 'wordpress',
+ `${themeLabel} is missing a "style.css" Version header.`,
+ );
+ }
+
+ const functionsPath = path.join(themeDir, 'functions.php');
+ if (fs.existsSync(functionsPath)) {
+ const functions = readTextFile(functionsPath);
+ if (!functions.includes(`${sourceLabel} child theme hooks.`)) {
+ validator.addError(
+ 'wordpress',
+ `${themeLabel} "functions.php" should describe the generated theme with ${expected(`${sourceLabel} child theme hooks.`)}.`,
+ );
+ }
+ }
+}
+
+function validateFrontendMetadata(validator, contract) {
+ const { themeDir, themeLabel, machineName, description, sourceProject, sourcePackage } =
+ contract;
+ const packagePath = path.join(themeDir, 'package.json');
+
+ if (!fs.existsSync(packagePath)) {
+ validator.addError('frontend', `${themeLabel} is missing "package.json".`);
+ } else {
+ const parsed = readJsonFile(packagePath);
+
+ checkEqual(
+ validator.addError,
+ 'frontend',
+ themeLabel,
+ 'package.json',
+ 'name',
+ parsed.name,
+ machineName,
+ );
+ checkEqual(
+ validator.addError,
+ 'frontend',
+ themeLabel,
+ 'package.json',
+ 'description',
+ normalizeWhitespace(parsed.description),
+ description,
+ );
+ checkEqual(
+ validator.addError,
+ 'frontend',
+ themeLabel,
+ 'package.json',
+ 'license',
+ parsed.license,
+ sourcePackage.license,
+ );
+ checkEqual(
+ validator.addError,
+ 'frontend',
+ themeLabel,
+ 'package.json',
+ 'dependencies["@emulsify/core"]',
+ parsed.dependencies?.['@emulsify/core'],
+ sourcePackage.dependencies?.['@emulsify/core'],
+ );
+
+ for (const script of REQUIRED_DOCUMENTED_SCRIPTS) {
+ const value = parsed.scripts?.[script];
+ if (typeof value !== 'string' || value.trim() === '') {
+ validator.addError(
+ 'frontend',
+ `${themeLabel} "package.json" is missing a non-empty ${expected(script)} script.`,
+ );
+ }
+ }
+
+ validateScriptReferences(validator, contract, parsed.scripts || {});
+ }
+
+ const projectPath = path.join(themeDir, 'project.emulsify.json');
+
+ if (!fs.existsSync(projectPath)) {
+ validator.addError(
+ 'frontend',
+ `${themeLabel} is missing "project.emulsify.json".`,
+ );
+ return;
+ }
+
+ const parsedProject = readJsonFile(projectPath);
+
+ if (!parsedProject.project || typeof parsedProject.project !== 'object') {
+ validator.addError(
+ 'frontend',
+ `${themeLabel} requires a project object in "project.emulsify.json"; found ${expected(parsedProject.project)}.`,
+ );
+ return;
+ }
+
+ const project = parsedProject.project;
+
+ for (const [key, value] of [
+ ['platform', sourceProject.platform],
+ ['machineName', machineName],
+ ['generatedFrom', sourceProject.generatedFrom],
+ ['generatedFromVersion', sourceProject.generatedFromVersion],
+ ['description', description],
+ ]) {
+ checkEqual(
+ validator.addError,
+ 'frontend',
+ themeLabel,
+ 'project.emulsify.json',
+ `project.${key}`,
+ key === 'description' ? normalizeWhitespace(project[key]) : project[key],
+ value,
+ );
+ }
+
+ if (typeof project.name !== 'string' || project.name.trim() === '') {
+ validator.addError(
+ 'frontend',
+ `${themeLabel} requires a non-empty project.name in "project.emulsify.json"; found ${expected(project.name)}.`,
+ );
+ }
+
+ if (!isDeepStrictEqual(parsedProject.starter, contract.sourceProjectFile.starter)) {
+ validator.addError(
+ 'frontend',
+ `${themeLabel} has inconsistent generated-source repository metadata in "project.emulsify.json"; expected ${expected(contract.sourceProjectFile.starter)}, found ${expected(parsedProject.starter)}.`,
+ );
+ }
+}
+
+function validateScriptReferences(validator, contract, scripts) {
+ const { themeDir, themeLabel } = contract;
+
+ for (const [name, command] of Object.entries(scripts)) {
+ if (typeof command !== 'string') {
+ continue;
+ }
+
+ const references = [
+ ...command.matchAll(/--(?:config|ignore-path)[= ]([^\s'"]+)/g),
+ ...command.matchAll(/(?:^|\s)(config\/[^\s'"]+)/g),
+ ].map((match) => match[1]);
+
+ for (const reference of references) {
+ if (reference.startsWith('node_modules/')) {
+ continue;
+ }
+
+ const resolved = path.resolve(themeDir, reference);
+
+ if (!resolved.startsWith(path.resolve(themeDir))) {
+ validator.addError(
+ 'references',
+ `${themeLabel} script ${expected(name)} references ${expected(reference)} outside the generated child theme.`,
+ );
+ continue;
+ }
+
+ if (!fs.existsSync(resolved)) {
+ validator.addError(
+ 'references',
+ `${themeLabel} script ${expected(name)} references missing local path ${expected(reference)}.`,
+ );
+ }
+ }
+ }
+}
+
+function validateMarkdownReferences(validator, contract, entries) {
+ const { themeDir, themeLabel } = contract;
+ const themeRoot = path.resolve(themeDir);
+
+ for (const entry of entries) {
+ if (!entry.file || !entry.relativePath.endsWith('.md')) {
+ continue;
+ }
+
+ const contents = readTextFile(path.join(themeDir, entry.relativePath));
+
+ for (const match of contents.matchAll(/\[[^\]]*\]\(([^)]+)\)/g)) {
+ const target = match[1].replace(/^<|>$/g, '').split('#')[0].trim();
+
+ if (target === '' || /^(?:[a-z][a-z0-9+.-]*:|\/\/|#)/i.test(target)) {
+ continue;
+ }
+
+ const resolved = path.resolve(
+ path.dirname(path.join(themeDir, entry.relativePath)),
+ target,
+ );
+
+ if (!resolved.startsWith(themeRoot)) {
+ validator.addError(
+ 'references',
+ `${themeLabel} has documentation link ${expected(target)} in ${expected(entry.relativePath)} that resolves outside the generated child theme; expected a self-contained path or published package.`,
+ );
+ continue;
+ }
+
+ if (!fs.existsSync(resolved)) {
+ validator.addError(
+ 'references',
+ `${themeLabel} has documentation link ${expected(target)} in ${expected(entry.relativePath)} that resolves to missing local path ${expected(path.relative(themeDir, resolved))}; expected it inside the generated child theme.`,
+ );
+ }
+ }
+ }
+}
+
+function validateGeneratedDocumentation(validator, contract) {
+ const { themeDir, themeLabel, machineName, displayName, description } = contract;
+
+ let result;
+
+ try {
+ result = validateDocumentation({ generatedTheme: themeDir });
+ } catch (error) {
+ validator.addError('documentation', `${themeLabel} ${error.message}`);
+ return;
+ }
+
+ for (const error of result.errors) {
+ validator.addError('documentation', `${themeLabel} ${error}`);
+ }
+
+ const readmePath = path.join(themeDir, 'README.md');
+
+ if (!fs.existsSync(readmePath)) {
+ return;
+ }
+
+ const readme = readTextFile(readmePath);
+ const project = readJsonFile(path.join(themeDir, 'project.emulsify.json')).project;
+ const coreRange = readJsonFile(path.join(themeDir, 'package.json'))?.dependencies?.[
+ '@emulsify/core'
+ ];
+
+ const expectedValues = [
+ ['display name', normalizeWhitespace(displayName)],
+ ['machine name', normalizeWhitespace(machineName)],
+ ['description', description],
+ ['generated source project', project?.generatedFrom],
+ ['generated source version', project?.generatedFromVersion],
+ ['Emulsify Core range', coreRange],
+ ];
+
+ for (const [label, value] of expectedValues) {
+ if (!value) {
+ continue;
+ }
+
+ if (!readme.includes(value)) {
+ validator.addError(
+ 'documentation',
+ `${themeLabel} README.md is missing its ${label} ${expected(value)}; expected project-specific generation metadata.`,
+ );
+ }
+ }
+}
+
+function validatePlaceholders(validator, contract, entries) {
+ const {
+ themeDir,
+ themeLabel,
+ machineName,
+ displayName,
+ sourceLabel,
+ description,
+ sourceMachineName,
+ sourceDisplayName,
+ sourceDescription,
+ } = contract;
+
+ const staleValues = [
+ {
+ category: 'starter display name',
+ value: sourceDisplayName,
+ replacement: displayName,
+ },
+ {
+ category: 'placeholder description',
+ value: sourceDescription,
+ replacement: 'the requested description',
+ skip: sourceDescription === description,
+ },
+ {
+ category: 'documentation token',
+ value: '%%EMULSIFY_*%%',
+ pattern: DOCUMENTATION_TOKEN_PATTERN,
+ replacement: 'project-specific generated documentation',
+ },
+ {
+ category: 'legacy placeholder',
+ value: 'EMULSIFY_NAME',
+ replacement: displayName,
+ },
+ {
+ category: 'retired frontend tooling',
+ value: 'Webpack',
+ pattern: /\bwebpack\b/i,
+ replacement: 'the Vite build workflow',
+ markdownOnly: true,
+ },
+ {
+ category: 'retired theme terminology',
+ value: 'subtheme',
+ pattern: /\bsub[ -]?theme\b/i,
+ replacement: 'child theme',
+ markdownOnly: true,
+ },
+ ].filter((stale) => !stale.skip && stale.value);
+
+ const machineNamePattern = new RegExp(escapeRegExp(sourceMachineName), 'i');
+ const knownValues = [machineName, displayName, sourceLabel, description].filter(
+ Boolean,
+ );
+
+ for (const entry of entries) {
+ if (!entry.file) {
+ continue;
+ }
+
+ const absolute = path.join(themeDir, entry.relativePath);
+ const buffer = fs.readFileSync(absolute);
+
+ if (isBinary(buffer)) {
+ continue;
+ }
+
+ const isMarkdown = entry.relativePath.endsWith('.md');
+ const lines = buffer.toString('utf8').split(/\r?\n/);
+
+ for (let index = 0; index < lines.length; index += 1) {
+ const line = lines[index];
+ const location = `${entry.relativePath}:${index + 1}`;
+
+ for (const stale of staleValues) {
+ if (stale.markdownOnly && !isMarkdown) {
+ continue;
+ }
+
+ const hit = stale.pattern
+ ? stale.pattern.test(line)
+ : line.includes(stale.value);
+
+ if (hit) {
+ validator.addError(
+ 'placeholders',
+ `${themeLabel} contains stale ${stale.category} ${expected(stale.value)} in ${expected(location)}; expected ${expected(stale.replacement)}.`,
+ );
+ }
+ }
+
+ // Strip the project's own identity before looking for the starter slug so
+ // a project legitimately named after the starter cannot trip the check.
+ let residual = line;
+ for (const value of knownValues) {
+ residual = residual.split(value).join('');
+ }
+
+ if (machineNamePattern.test(residual)) {
+ validator.addError(
+ 'placeholders',
+ `${themeLabel} contains stale starter machine name ${expected(sourceMachineName)} in ${expected(location)}; expected ${expected(machineName)}.`,
+ );
+ }
+ }
+ }
+}
+
+function validateBuiltAssets(validator, contract) {
+ const { themeDir, themeLabel } = contract;
+ const distDir = path.join(themeDir, 'dist');
+
+ if (!fs.existsSync(distDir)) {
+ validator.addError(
+ 'build',
+ `${themeLabel} is missing "dist" after a build; the parent theme discovers built output there.`,
+ );
+ }
+}
+
+function validateGeneratedTheme(options) {
+ const {
+ themeDir,
+ machineName,
+ displayName,
+ description,
+ sourceDir,
+ checkBuiltAssets = false,
+ } = options;
+
+ const validator = createValidator();
+ validator.addError = validator.addError.bind(validator);
+
+ if (!fs.existsSync(themeDir) || !fs.statSync(themeDir).isDirectory()) {
+ validator.addError(
+ 'generation',
+ `Generated theme directory ${expected(themeDir)} does not exist.`,
+ );
+ for (const key of Object.keys(SECTION_LABELS)) {
+ if (key !== 'generation') {
+ validator.skip(key);
+ }
+ }
+ return validator;
+ }
+
+ const sourcePackage = readJsonFile(path.join(sourceDir, 'package.json'));
+ const sourceProjectFile = readJsonFile(path.join(sourceDir, 'project.emulsify.json'));
+ const sourceHeaders = readThemeHeaders(path.join(sourceDir, 'style.css'));
+
+ const contract = {
+ themeDir,
+ themeLabel: `Generated theme ${expected(machineName)}`,
+ machineName,
+ displayName: normalizeWhitespace(displayName),
+ sourceLabel: normalizeWhitespace(displayName).replace(/[^\p{L}\p{N} -]+/gu, '').replace(/ +/g, ' ').trim(),
+ description: normalizeWhitespace(description),
+ parent: 'emulsify',
+ sourceDir,
+ sourcePackage,
+ sourceProjectFile,
+ sourceProject: sourceProjectFile.project,
+ sourceMachineName: sourceProjectFile.project.machineName,
+ sourceDisplayName: sourceHeaders['Theme Name'],
+ sourceDescription: normalizeWhitespace(sourceHeaders.Description),
+ };
+
+ const { entries } = validateGenerationShape(validator, contract);
+ validateWordPressMetadata(validator, contract);
+ validateFrontendMetadata(validator, contract);
+ validateMarkdownReferences(validator, contract, entries);
+ validateGeneratedDocumentation(validator, contract);
+ validatePlaceholders(validator, contract, entries);
+
+ if (checkBuiltAssets) {
+ validateBuiltAssets(validator, contract);
+ } else {
+ validator.skip('build');
+ }
+
+ return validator;
+}
+
+if (require.main === module) {
+ const argv = process.argv.slice(2);
+ const checkBuiltAssets = argv.includes('--check-built-assets');
+ const positional = argv.filter((value) => !value.startsWith('--'));
+
+ if (positional.length !== 5) {
+ console.error(
+ 'Usage: generated-theme-contract.cjs [--check-built-assets] ',
+ );
+ process.exit(2);
+ }
+
+ const [themeDir, machineName, displayName, description, sourceDir] = positional;
+ const validator = validateGeneratedTheme({
+ themeDir: path.resolve(themeDir),
+ machineName,
+ displayName,
+ description,
+ sourceDir: path.resolve(sourceDir),
+ checkBuiltAssets,
+ });
+
+ console.log(validator.format());
+
+ if (validator.errors.length > 0) {
+ process.exit(1);
+ }
+}
+
+module.exports = { validateGeneratedTheme, normalizeWhitespace };
diff --git a/.github/scripts/generated-theme-contract.test.cjs b/.github/scripts/generated-theme-contract.test.cjs
new file mode 100644
index 0000000..dad3ed6
--- /dev/null
+++ b/.github/scripts/generated-theme-contract.test.cjs
@@ -0,0 +1,260 @@
+#!/usr/bin/env node
+
+// Focused tests for the generated child theme contract. These build a real
+// generated theme with the standalone starter hook, then mutate it to prove each
+// class of contract failure is actually detected.
+
+const assert = require('node:assert/strict');
+const childProcess = require('node:child_process');
+const fs = require('node:fs');
+const os = require('node:os');
+const path = require('node:path');
+const { test } = require('node:test');
+
+const { validateGeneratedTheme } = require('./generated-theme-contract.cjs');
+
+const repoRoot = path.resolve(__dirname, '../..');
+const starterRoot = path.join(repoRoot, 'whisk');
+const excludedCopySegments = new Set([
+ '.coverage',
+ '.git',
+ '.out',
+ '.cache',
+ 'dist',
+ 'node_modules',
+]);
+
+const machineName = 'acme-site';
+const displayName = 'Acme Site';
+const description = 'Marketing site for Acme, Inc. (rebuild).';
+
+function copyStarter(source, destination) {
+ fs.mkdirSync(destination, { recursive: true });
+
+ for (const entry of fs.readdirSync(source, { withFileTypes: true })) {
+ if (excludedCopySegments.has(entry.name)) {
+ continue;
+ }
+
+ const sourcePath = path.join(source, entry.name);
+ const destinationPath = path.join(destination, entry.name);
+
+ if (entry.isDirectory()) {
+ copyStarter(sourcePath, destinationPath);
+ continue;
+ }
+
+ if (entry.isFile()) {
+ fs.copyFileSync(sourcePath, destinationPath);
+ }
+ }
+}
+
+function writeJson(filePath, data) {
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
+ fs.writeFileSync(filePath, `${JSON.stringify(data, null, 2)}\n`);
+}
+
+function readJson(filePath) {
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
+}
+
+/**
+ * Generates a throwaway child theme and returns its directory.
+ */
+function generateTheme() {
+ const workRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'emulsify-contract-'));
+ const target = path.join(workRoot, machineName);
+
+ copyStarter(starterRoot, target);
+
+ const project = readJson(path.join(target, 'project.emulsify.json'));
+ project.project.name = displayName;
+ project.project.machineName = machineName;
+ project.project.description = description;
+ writeJson(path.join(target, 'project.emulsify.json'), project);
+
+ childProcess.execFileSync(process.execPath, [path.join(target, '.cli/init.js')], {
+ cwd: target,
+ stdio: 'pipe',
+ });
+
+ return { workRoot, target };
+}
+
+function validate(target) {
+ return validateGeneratedTheme({
+ themeDir: target,
+ machineName,
+ displayName,
+ description,
+ sourceDir: starterRoot,
+ });
+}
+
+function withGeneratedTheme(callback) {
+ const { workRoot, target } = generateTheme();
+
+ try {
+ return callback(target);
+ } finally {
+ fs.rmSync(workRoot, { recursive: true, force: true });
+ }
+}
+
+test('accepts a freshly generated child theme', () => {
+ withGeneratedTheme((target) => {
+ const result = validate(target);
+ assert.equal(
+ result.errors.length,
+ 0,
+ `Expected a clean generated theme:\n${result.format()}`,
+ );
+ assert.match(result.format(), /PASS documentation/);
+ assert.match(result.format(), /PASS placeholder replacement/);
+ });
+});
+
+test('requires the generated documentation set', () => {
+ withGeneratedTheme((target) => {
+ fs.rmSync(path.join(target, 'README.md'));
+ fs.rmSync(path.join(target, 'docs'), { recursive: true, force: true });
+
+ const result = validate(target);
+ assert.ok(result.errors.length > 0);
+ assert.match(result.format(), /FAIL generation/);
+ assert.match(result.format(), /missing required generated file "README\.md"/);
+ });
+});
+
+test('reports leftover documentation tokens', () => {
+ withGeneratedTheme((target) => {
+ const readmePath = path.join(target, 'README.md');
+ fs.writeFileSync(
+ readmePath,
+ `${fs.readFileSync(readmePath, 'utf8')}\n%%EMULSIFY_THEME_NAME%%\n`,
+ );
+
+ const result = validate(target);
+ assert.match(result.format(), /FAIL placeholder replacement/);
+ assert.match(result.format(), /documentation token/);
+ });
+});
+
+test('reports a stale starter machine name', () => {
+ withGeneratedTheme((target) => {
+ const templatePath = path.join(target, 'templates/page.twig');
+ fs.writeFileSync(
+ templatePath,
+ fs.readFileSync(templatePath, 'utf8').replace(`${machineName}-page`, 'whisk-page'),
+ );
+
+ const result = validate(target);
+ assert.match(result.format(), /FAIL placeholder replacement/);
+ assert.match(result.format(), /stale starter machine name "whisk"/);
+ });
+});
+
+test('reports a documented npm command that package.json does not expose', () => {
+ withGeneratedTheme((target) => {
+ const docPath = path.join(target, 'docs/support-information.md');
+ fs.writeFileSync(
+ docPath,
+ `${fs.readFileSync(docPath, 'utf8')}\n\`\`\`bash\nnpm run does-not-exist\n\`\`\`\n`,
+ );
+
+ const result = validate(target);
+ assert.match(result.format(), /FAIL documentation/);
+ assert.match(result.format(), /has no "does-not-exist" script/);
+ });
+});
+
+test('reports documentation links that escape the generated theme', () => {
+ withGeneratedTheme((target) => {
+ const readmePath = path.join(target, 'README.md');
+ fs.writeFileSync(
+ readmePath,
+ `${fs.readFileSync(readmePath, 'utf8')}\n[Escape](../../secrets.md)\n`,
+ );
+
+ const result = validate(target);
+ assert.match(result.format(), /FAIL file references/);
+ assert.match(result.format(), /resolves outside the generated child theme/);
+ });
+});
+
+test('requires WordPress theme identity to match the request', () => {
+ withGeneratedTheme((target) => {
+ const stylePath = path.join(target, 'style.css');
+ fs.writeFileSync(
+ stylePath,
+ fs.readFileSync(stylePath, 'utf8').replace('Template: emulsify', 'Template: twentytwentyfive'),
+ );
+
+ const result = validate(target);
+ assert.match(result.format(), /FAIL WordPress metadata/);
+ assert.match(result.format(), /Template/);
+ });
+});
+
+test('requires generated project lineage metadata', () => {
+ withGeneratedTheme((target) => {
+ const projectPath = path.join(target, 'project.emulsify.json');
+ const project = readJson(projectPath);
+ project.project.generatedFromVersion = '0.0.0';
+ writeJson(projectPath, project);
+
+ const result = validate(target);
+ assert.match(result.format(), /FAIL frontend metadata/);
+ assert.match(result.format(), /project\.generatedFromVersion/);
+ });
+});
+
+test('requires generated project description metadata', () => {
+ withGeneratedTheme((target) => {
+ const projectPath = path.join(target, 'project.emulsify.json');
+ const project = readJson(projectPath);
+ delete project.project.description;
+ writeJson(projectPath, project);
+
+ const result = validate(target);
+ assert.match(result.format(), /FAIL frontend metadata/);
+ assert.match(result.format(), /project\.description/);
+ });
+});
+
+test('rejects generation-only tooling left in the generated theme', () => {
+ withGeneratedTheme((target) => {
+ fs.mkdirSync(path.join(target, '.cli'), { recursive: true });
+ fs.writeFileSync(path.join(target, '.cli/init.js'), '// leftover\n');
+
+ const result = validate(target);
+ assert.match(result.format(), /FAIL generation/);
+ assert.match(result.format(), /generation-only or build path "\.cli"/);
+ });
+});
+
+test('rejects symbolic links in generated output', () => {
+ withGeneratedTheme((target) => {
+ fs.symlinkSync(path.join(target, 'style.css'), path.join(target, 'linked.css'));
+
+ const result = validate(target);
+ assert.match(result.format(), /FAIL generation/);
+ assert.match(result.format(), /contains symbolic link/);
+ });
+});
+
+test('rejects retired terminology in generated markdown', () => {
+ withGeneratedTheme((target) => {
+ const docPath = path.join(target, 'docs/development.md');
+ fs.writeFileSync(
+ docPath,
+ `${fs.readFileSync(docPath, 'utf8')}\nBuild the subtheme with Webpack.\n`,
+ );
+
+ const result = validate(target);
+ assert.match(result.format(), /FAIL placeholder replacement/);
+ assert.match(result.format(), /retired frontend tooling/);
+ assert.match(result.format(), /retired theme terminology/);
+ });
+});
diff --git a/.github/scripts/generation-parity-harness.php b/.github/scripts/generation-parity-harness.php
new file mode 100644
index 0000000..95ed048
--- /dev/null
+++ b/.github/scripts/generation-parity-harness.php
@@ -0,0 +1,91 @@
+