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', '
Hero
' ); + 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 = '
Example PDF
'; + + 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 @@ +