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/.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..757c824
--- /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.
+- [ ] For the 2.0 release branch merge, run the manual `WordPress Theme Readiness` workflow on `release-2.x` with `wordpress_fixture` enabled and confirm both `Practical theme readiness` and `WordPress fixture smoke` pass. Mark N/A for routine PRs.
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..d4a89ef
--- /dev/null
+++ b/.github/scripts/asset-manifest-smoke.php
@@ -0,0 +1,457 @@
+ $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 );
+
+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{}' );
+
+ ( new Emulsify\Theme\Runtime\Assets() )->styles();
+
+ emulsify_asset_manifest_smoke_assert(
+ isset( $GLOBALS['emulsify_asset_manifest_smoke_styles']['emulsify-global-fallback'] ),
+ 'Invalid manifest should fall back safely to recursive discovery.'
+ );
+
+ $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..173c975
--- /dev/null
+++ b/.github/scripts/block-scoped-assets-smoke.php
@@ -0,0 +1,496 @@
+ $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';
+
+$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',
+ ),
+ ),
+ 'js' => array(
+ array(
+ 'path' => 'card.js',
+ 'version' => 'metadata-js',
+ 'dependencies' => array( 'jquery' ),
+ 'module' => false,
+ ),
+ ),
+ ),
+ 'editor' => array(
+ 'css' => array(
+ array(
+ 'path' => 'editor.css',
+ 'version' => 'metadata-editor-css',
+ ),
+ ),
+ 'js' => array(
+ array(
+ 'path' => 'editor.js',
+ 'version' => 'metadata-editor-js',
+ 'dependencies' => array( 'wp-blocks' ),
+ ),
+ ),
+ ),
+ ),
+ )
+ );
+ emulsify_block_assets_smoke_write( $child . '/dist/components/card/card.twig', 'Card ' );
+ emulsify_block_assets_smoke_write( $child . '/dist/components/card/card.css', '.card{}' );
+ emulsify_block_assets_smoke_write( $child . '/dist/components/card/card.js', 'window.card = true;' );
+ emulsify_block_assets_smoke_write( $child . '/dist/components/card/editor.css', '.editor{}' );
+ emulsify_block_assets_smoke_write( $child . '/dist/components/card/editor.js', 'window.editorCard = true;' );
+
+ emulsify_block_assets_smoke_write_json(
+ $child . '/dist/components/hero/hero.component.json',
+ array(
+ 'title' => 'Hero',
+ 'assets' => array(
+ 'frontend' => array(
+ 'css' => array(
+ array(
+ 'path' => 'metadata.css',
+ 'version' => 'metadata-hero-css',
+ ),
+ ),
+ ),
+ ),
+ )
+ );
+ emulsify_block_assets_smoke_write( $child . '/dist/components/hero/hero.twig', '' );
+ emulsify_block_assets_smoke_write( $child . '/dist/components/hero/metadata.css', '.metadata-hero{}' );
+ emulsify_block_assets_smoke_write( $child . '/dist/components/hero/manifest.css', '.manifest-hero{}' );
+
+ emulsify_block_assets_smoke_write_json(
+ $child . '/dist/components/plain/plain.component.json',
+ array(
+ 'title' => 'Plain',
+ )
+ );
+ emulsify_block_assets_smoke_write( $child . '/dist/components/plain/plain.twig', '
Plain
' );
+
+ emulsify_block_assets_smoke_write_json(
+ $child . '/dist/components/native/block.json',
+ array(
+ 'name' => 'emulsify/native',
+ 'style' => 'file:./style.css',
+ 'viewScript' => 'file:./view.js',
+ 'editorScript' => 'file:./editor.js',
+ )
+ );
+ emulsify_block_assets_smoke_write( $child . '/dist/components/native/style.css', '.native{}' );
+ emulsify_block_assets_smoke_write( $child . '/dist/components/native/view.js', 'window.nativeView = true;' );
+ emulsify_block_assets_smoke_write( $child . '/dist/components/native/editor.js', 'window.nativeEditor = true;' );
+
+ emulsify_block_assets_smoke_write_json(
+ $child . '/dist/emulsify-assets.json',
+ array(
+ 'assets' => array(
+ 'blocks' => array(
+ 'acf/emulsify-hero' => array(
+ 'frontend' => array(
+ 'css' => array(
+ array(
+ 'path' => 'components/hero/manifest.css',
+ 'relative' => 'hero-manifest.css',
+ 'version' => 'manifest-hero-css',
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ )
+ );
+
+ require_once $repo_root . '/includes/Support/FileDiscovery.php';
+ require_once $repo_root . '/includes/Support/AssetRecord.php';
+ require_once $repo_root . '/includes/Support/AssetEnqueuer.php';
+ require_once $repo_root . '/includes/Support/Diagnostics.php';
+ require_once $repo_root . '/includes/Support/AssetManifest.php';
+ require_once $repo_root . '/includes/Runtime/Assets.php';
+ require_once $repo_root . '/includes/Blocks/ComponentLocator.php';
+ require_once $repo_root . '/includes/Blocks/AcfBlocks.php';
+ require_once $repo_root . '/includes/Blocks/NativeBlocks.php';
+
+ add_filter(
+ 'emulsify_theme_acf_block_asset_records',
+ static function ( array $records, array $component ): array {
+ if ( 'card' === $component['relative'] && isset( $records['frontend']['css'][0] ) ) {
+ $records['frontend']['css'][0]['version'] = 'filtered-card-css';
+ }
+
+ return $records;
+ },
+ 10,
+ 2
+ );
+
+ $acf_blocks = new Emulsify\Theme\Blocks\AcfBlocks( new Emulsify\Theme\Blocks\ComponentLocator() );
+ $acf_blocks->register_blocks();
+
+ 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..ff4b18f
--- /dev/null
+++ b/.github/scripts/child-theme-generator-smoke.php
@@ -0,0 +1,378 @@
+
+ */
+ 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 {
+ return is_dir( $target ) || mkdir( $target, 0777, true );
+ }
+}
+
+if ( ! function_exists( 'switch_theme' ) ) {
+ function switch_theme( string $stylesheet ): void {
+ $GLOBALS['emulsify_activated_theme'] = $stylesheet;
+ }
+}
+
+/**
+ * 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;
+}
+
+$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;
+
+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' );
+ 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"
+ );
+
+ 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( '2.0.0' === $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( '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.' );
+
+ $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( '2.0.0' === $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.' );
+
+ WP_CLI::$messages = array();
+ $GLOBALS['emulsify_activated_theme'] = null;
+ $dry_run_destination = $theme_root . '/dry-run-child';
+
+ if ( ! mkdir( $dry_run_destination, 0777, true ) ) {
+ throw new RuntimeException( sprintf( 'Could not create dry-run fixture: %s', $dry_run_destination ) );
+ }
+
+ file_put_contents( $dry_run_destination . '/keep-me.txt', 'dry-run' );
+
+ $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..4318a1b
--- /dev/null
+++ b/.github/scripts/component-locator-smoke.php
@@ -0,0 +1,567 @@
+ $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.'
+ );
+
+ 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/editor-enhancements-smoke.php b/.github/scripts/editor-enhancements-smoke.php
new file mode 100644
index 0000000..ca89421
--- /dev/null
+++ b/.github/scripts/editor-enhancements-smoke.php
@@ -0,0 +1,352 @@
+ $accepted_args,
+ 'callback' => $callback,
+ );
+
+ ksort( $GLOBALS['emulsify_editor_enhancements_smoke_hooks'][ $hook ] );
+
+ return true;
+ }
+}
+
+if ( ! function_exists( 'add_action' ) ) {
+ function add_action( string $hook, callable $callback, int $priority = 10, int $accepted_args = 1 ): bool {
+ return add_filter( $hook, $callback, $priority, $accepted_args );
+ }
+}
+
+if ( ! function_exists( 'apply_filters' ) ) {
+ function apply_filters( string $hook, $value, ...$arguments ) {
+ if ( empty( $GLOBALS['emulsify_editor_enhancements_smoke_hooks'][ $hook ] ) ) {
+ return $value;
+ }
+
+ foreach ( $GLOBALS['emulsify_editor_enhancements_smoke_hooks'][ $hook ] as $callbacks ) {
+ foreach ( $callbacks as $callback ) {
+ $value = call_user_func_array(
+ $callback['callback'],
+ array_slice(
+ array_merge( array( $value ), $arguments ),
+ 0,
+ $callback['accepted_args']
+ )
+ );
+ }
+ }
+
+ return $value;
+ }
+}
+
+if ( ! function_exists( 'do_action' ) ) {
+ function do_action( string $hook, ...$arguments ): void {
+ if ( empty( $GLOBALS['emulsify_editor_enhancements_smoke_hooks'][ $hook ] ) ) {
+ return;
+ }
+
+ foreach ( $GLOBALS['emulsify_editor_enhancements_smoke_hooks'][ $hook ] as $callbacks ) {
+ foreach ( $callbacks as $callback ) {
+ call_user_func_array(
+ $callback['callback'],
+ array_slice( $arguments, 0, $callback['accepted_args'] )
+ );
+ }
+ }
+ }
+}
+
+if ( ! function_exists( 'get_stylesheet_directory' ) ) {
+ function get_stylesheet_directory(): string {
+ return $GLOBALS['emulsify_editor_enhancements_smoke_child'];
+ }
+}
+
+if ( ! function_exists( 'get_template_directory' ) ) {
+ function get_template_directory(): string {
+ return $GLOBALS['emulsify_editor_enhancements_smoke_parent'];
+ }
+}
+
+if ( ! function_exists( 'get_stylesheet_directory_uri' ) ) {
+ function get_stylesheet_directory_uri(): string {
+ return 'https://example.test/child';
+ }
+}
+
+if ( ! function_exists( 'get_template_directory_uri' ) ) {
+ function get_template_directory_uri(): string {
+ return 'https://example.test/parent';
+ }
+}
+
+if ( ! function_exists( 'wp_enqueue_style' ) ) {
+ function wp_enqueue_style( string $handle, string $src, array $deps = array(), $ver = false ): void {
+ $GLOBALS['emulsify_editor_enhancements_smoke_styles'][] = compact( 'handle', 'src', 'deps', 'ver' );
+ }
+}
+
+if ( ! function_exists( 'wp_enqueue_script' ) ) {
+ function wp_enqueue_script( string $handle, string $src, array $deps = array(), $ver = false, $args = array() ): void {
+ $GLOBALS['emulsify_editor_enhancements_smoke_scripts'][] = compact( 'handle', 'src', 'deps', 'ver', 'args' );
+ }
+}
+
+if ( ! function_exists( 'wp_add_inline_script' ) ) {
+ function wp_add_inline_script( string $handle, string $data, string $position = 'after' ): bool {
+ $GLOBALS['emulsify_editor_enhancements_smoke_inline'][] = compact( 'handle', 'data', 'position' );
+
+ return true;
+ }
+}
+
+if ( ! function_exists( 'wp_json_encode' ) ) {
+ function wp_json_encode( $value ) {
+ return json_encode( $value );
+ }
+}
+
+if ( ! function_exists( 'sanitize_key' ) ) {
+ function sanitize_key( string $key ): string {
+ return trim( preg_replace( '/[^a-z0-9_-]+/', '-', strtolower( $key ) ), '-' );
+ }
+}
+
+if ( ! function_exists( 'wp_get_attachment_caption' ) ) {
+ function wp_get_attachment_caption( int $attachment_id ) {
+ return $GLOBALS['emulsify_editor_enhancements_smoke_captions'][ $attachment_id ] ?? '';
+ }
+}
+
+if ( ! function_exists( 'esc_attr' ) ) {
+ function esc_attr( string $value ): string {
+ return htmlspecialchars( $value, ENT_QUOTES, 'UTF-8' );
+ }
+}
+
+if ( ! function_exists( 'esc_html' ) ) {
+ function esc_html( string $value ): string {
+ return htmlspecialchars( $value, ENT_QUOTES, 'UTF-8' );
+ }
+}
+
+/**
+ * Fails the smoke script when an assertion is false.
+ *
+ * @param bool $condition Assertion condition.
+ * @param string $message Failure message.
+ * @return void
+ */
+function emulsify_editor_enhancements_smoke_assert( bool $condition, string $message ): void {
+ if ( ! $condition ) {
+ throw new RuntimeException( $message );
+ }
+}
+
+/**
+ * Writes a fixture file.
+ *
+ * @param string $path File path.
+ * @param string $contents File contents.
+ * @return void
+ */
+function emulsify_editor_enhancements_smoke_write( string $path, string $contents ): void {
+ $directory = dirname( $path );
+
+ if ( ! is_dir( $directory ) && ! mkdir( $directory, 0777, true ) ) {
+ throw new RuntimeException( sprintf( 'Could not create fixture directory: %s', $directory ) );
+ }
+
+ if ( false === file_put_contents( $path, $contents ) ) {
+ throw new RuntimeException( sprintf( 'Could not write fixture file: %s', $path ) );
+ }
+}
+
+/**
+ * Recursively removes a path.
+ *
+ * @param string $path Path to remove.
+ * @return void
+ */
+function emulsify_editor_enhancements_smoke_remove( string $path ): void {
+ if ( ! file_exists( $path ) ) {
+ return;
+ }
+
+ if ( is_file( $path ) || is_link( $path ) ) {
+ unlink( $path );
+ return;
+ }
+
+ $iterator = new RecursiveIteratorIterator(
+ new RecursiveDirectoryIterator( $path, RecursiveDirectoryIterator::SKIP_DOTS ),
+ RecursiveIteratorIterator::CHILD_FIRST
+ );
+
+ foreach ( $iterator as $item ) {
+ $item->isDir() && ! $item->isLink() ? rmdir( $item->getPathname() ) : unlink( $item->getPathname() );
+ }
+
+ rmdir( $path );
+}
+
+$repo_root = dirname( __DIR__, 2 );
+$work_root = sys_get_temp_dir() . '/emulsify-editor-enhancements-' . uniqid( '', true );
+$child = $work_root . '/child-theme';
+$parent = $work_root . '/parent-theme';
+
+$GLOBALS['emulsify_editor_enhancements_smoke_child'] = $child;
+$GLOBALS['emulsify_editor_enhancements_smoke_parent'] = $parent;
+
+try {
+ require_once $repo_root . '/includes/Support/FileDiscovery.php';
+ require_once $repo_root . '/includes/Support/AssetRecord.php';
+ require_once $repo_root . '/includes/Support/AssetEnqueuer.php';
+ require_once $repo_root . '/includes/Support/AssetManifest.php';
+ require_once $repo_root . '/includes/Editor/Enhancements.php';
+
+ $service = new Emulsify\Theme\Editor\Enhancements();
+ $service->register();
+
+ foreach ( array( 'enqueue_block_editor_assets', 'render_block_data', 'render_block' ) as $hook ) {
+ emulsify_editor_enhancements_smoke_assert(
+ isset( $GLOBALS['emulsify_editor_enhancements_smoke_hooks'][ $hook ] ),
+ sprintf( 'Editor enhancements should register the %s hook.', $hook )
+ );
+ }
+
+ $file_block = array(
+ 'blockName' => 'core/file',
+ 'attrs' => array(
+ 'id' => 42,
+ 'emulsifyShowMediaCaption' => true,
+ 'className' => 'existing-class',
+ ),
+ );
+ $content = '';
+
+ emulsify_editor_enhancements_smoke_assert(
+ $content === apply_filters( 'render_block', $content, $file_block, null ),
+ 'File captions should be no-op by default.'
+ );
+
+ foreach ( array( 'enqueue_block_editor_assets', 'render_block_data', 'render_block' ) as $hook ) {
+ unset( $GLOBALS['emulsify_editor_enhancements_smoke_hooks'][ $hook ] );
+ }
+
+ add_filter(
+ 'emulsify_theme_editor_enhancements_config',
+ static function ( array $config ): array {
+ $config['columnsEqualHeight']['enabled'] = true;
+ $config['fileCaption']['enabled'] = true;
+ $config['embedVariations']['enabled'] = true;
+ $config['placement'] = array(
+ 'enabled' => true,
+ 'blocks' => array( 'acf/example-hero' ),
+ 'singleton' => true,
+ 'requireTop' => true,
+ );
+
+ return $config;
+ }
+ );
+
+ $service = new Emulsify\Theme\Editor\Enhancements();
+ $service->register();
+
+ $asset_directories_seen = false;
+ $asset_files_seen = false;
+
+ add_filter(
+ 'emulsify_theme_editor_asset_directories',
+ static function ( array $directories, string $directory ) use ( &$asset_directories_seen ): array {
+ $asset_directories_seen = $asset_directories_seen || 'dist/global/editor' === $directory;
+
+ return $directories;
+ },
+ 10,
+ 2
+ );
+
+ add_filter(
+ 'emulsify_theme_editor_asset_files',
+ static function ( array $assets, string $directory ) use ( &$asset_files_seen ): array {
+ $asset_files_seen = $asset_files_seen || 'dist/global/editor' === $directory;
+
+ return $assets;
+ },
+ 10,
+ 2
+ );
+
+ $parsed = apply_filters( 'render_block_data', $file_block, $file_block, null );
+
+ emulsify_editor_enhancements_smoke_assert(
+ false !== strpos( $parsed['attrs']['className'], 'existing-class' ) && false !== strpos( $parsed['attrs']['className'], 'has-media-caption' ),
+ 'File caption render_block_data should append the configured class.'
+ );
+
+ $GLOBALS['emulsify_editor_enhancements_smoke_captions'][42] = 'Media caption';
+ $rendered = apply_filters( 'render_block', $content, $file_block, null );
+
+ emulsify_editor_enhancements_smoke_assert(
+ false !== strpos( $rendered, 'Media caption
' ),
+ 'File caption render_block should append the attachment caption.'
+ );
+
+ emulsify_editor_enhancements_smoke_write( $child . '/dist/global/editor/js/index.js', 'window.editorSmoke = true;' );
+ emulsify_editor_enhancements_smoke_write( $child . '/dist/global/editor/css/index.css', '.editor-smoke{}' );
+ emulsify_editor_enhancements_smoke_write( $parent . '/dist/global/editor/js/index.js', 'window.parentEditorSmoke = true;' );
+
+ do_action( 'enqueue_block_editor_assets' );
+
+ emulsify_editor_enhancements_smoke_assert(
+ $asset_directories_seen,
+ 'emulsify_theme_editor_asset_directories should run during editor asset discovery.'
+ );
+ emulsify_editor_enhancements_smoke_assert(
+ $asset_files_seen,
+ 'emulsify_theme_editor_asset_files should run during editor asset discovery.'
+ );
+ emulsify_editor_enhancements_smoke_assert(
+ 1 === count( $GLOBALS['emulsify_editor_enhancements_smoke_styles'] ),
+ 'Editor asset enqueue should include the child editor stylesheet.'
+ );
+ emulsify_editor_enhancements_smoke_assert(
+ 1 === count( $GLOBALS['emulsify_editor_enhancements_smoke_scripts'] ),
+ 'Editor asset enqueue should include the child editor script and skip duplicate parent relative paths.'
+ );
+ emulsify_editor_enhancements_smoke_assert(
+ in_array( 'wp-blocks', $GLOBALS['emulsify_editor_enhancements_smoke_scripts'][0]['deps'], true ),
+ 'Editor script should declare WordPress editor dependencies.'
+ );
+ emulsify_editor_enhancements_smoke_assert(
+ ! empty( $GLOBALS['emulsify_editor_enhancements_smoke_inline'] )
+ && false !== strpos( $GLOBALS['emulsify_editor_enhancements_smoke_inline'][0]['data'], 'emulsifyEditorEnhancements' )
+ && false !== strpos( $GLOBALS['emulsify_editor_enhancements_smoke_inline'][0]['data'], '"fileCaption":{"enabled":true' ),
+ 'Editor script should receive the filtered enhancement config.'
+ );
+
+ echo "Editor enhancements smoke checks passed.\n";
+} finally {
+ emulsify_editor_enhancements_smoke_remove( $work_root );
+}
diff --git a/.github/scripts/editor-policy-smoke.php b/.github/scripts/editor-policy-smoke.php
new file mode 100644
index 0000000..3ba0946
--- /dev/null
+++ b/.github/scripts/editor-policy-smoke.php
@@ -0,0 +1,380 @@
+ $accepted_args,
+ 'callback' => $callback,
+ );
+
+ ksort( $GLOBALS['emulsify_editor_policy_smoke_filters'][ $hook ] );
+
+ return true;
+ }
+}
+
+if ( ! function_exists( 'apply_filters' ) ) {
+ function apply_filters( string $hook, $value, ...$arguments ) {
+ if ( empty( $GLOBALS['emulsify_editor_policy_smoke_filters'][ $hook ] ) ) {
+ return $value;
+ }
+
+ foreach ( $GLOBALS['emulsify_editor_policy_smoke_filters'][ $hook ] as $callbacks ) {
+ foreach ( $callbacks as $callback ) {
+ $value = call_user_func_array(
+ $callback['callback'],
+ array_slice(
+ array_merge( array( $value ), $arguments ),
+ 0,
+ $callback['accepted_args']
+ )
+ );
+ }
+ }
+
+ return $value;
+ }
+}
+
+if ( ! function_exists( 'get_stylesheet_directory' ) ) {
+ function get_stylesheet_directory(): string {
+ return $GLOBALS['emulsify_editor_policy_smoke_child'];
+ }
+}
+
+if ( ! function_exists( 'get_template_directory' ) ) {
+ function get_template_directory(): string {
+ return $GLOBALS['emulsify_editor_policy_smoke_parent'];
+ }
+}
+
+if ( ! function_exists( 'get_post_type' ) ) {
+ function get_post_type( $post ): string {
+ return is_object( $post ) && isset( $post->post_type ) ? (string) $post->post_type : '';
+ }
+}
+
+if ( ! function_exists( 'current_user_can' ) ) {
+ function current_user_can( string $capability ): bool {
+ return in_array( $capability, $GLOBALS['emulsify_editor_policy_smoke_current_caps'], true );
+ }
+}
+
+/**
+ * Fails the smoke script when an assertion is false.
+ *
+ * @param bool $condition Assertion condition.
+ * @param string $message Failure message.
+ * @return void
+ */
+function emulsify_editor_policy_smoke_assert( bool $condition, string $message ): void {
+ if ( ! $condition ) {
+ throw new RuntimeException( $message );
+ }
+}
+
+/**
+ * Writes a fixture file.
+ *
+ * @param string $path File path.
+ * @param string $contents File contents.
+ * @return void
+ */
+function emulsify_editor_policy_smoke_write( string $path, string $contents ): void {
+ $directory = dirname( $path );
+
+ if ( ! is_dir( $directory ) && ! mkdir( $directory, 0777, true ) ) {
+ throw new RuntimeException( sprintf( 'Could not create fixture directory: %s', $directory ) );
+ }
+
+ if ( false === file_put_contents( $path, $contents ) ) {
+ throw new RuntimeException( sprintf( 'Could not write fixture file: %s', $path ) );
+ }
+}
+
+/**
+ * Recursively removes a path.
+ *
+ * @param string $path Path to remove.
+ * @return void
+ */
+function emulsify_editor_policy_smoke_remove( string $path ): void {
+ if ( ! file_exists( $path ) ) {
+ return;
+ }
+
+ if ( is_file( $path ) || is_link( $path ) ) {
+ unlink( $path );
+ return;
+ }
+
+ $iterator = new RecursiveIteratorIterator(
+ new RecursiveDirectoryIterator( $path, RecursiveDirectoryIterator::SKIP_DOTS ),
+ RecursiveIteratorIterator::CHILD_FIRST
+ );
+
+ foreach ( $iterator as $item ) {
+ $item->isDir() && ! $item->isLink() ? rmdir( $item->getPathname() ) : unlink( $item->getPathname() );
+ }
+
+ rmdir( $path );
+}
+
+$repo_root = dirname( __DIR__, 2 );
+$work_root = sys_get_temp_dir() . '/emulsify-editor-policy-' . uniqid( '', true );
+$child = $work_root . '/child-theme';
+$parent = $work_root . '/parent-theme';
+$context = (object) array(
+ 'post' => (object) array(
+ 'post_type' => 'page',
+ ),
+);
+
+$GLOBALS['emulsify_editor_policy_smoke_child'] = $child;
+$GLOBALS['emulsify_editor_policy_smoke_parent'] = $parent;
+
+try {
+ require_once $repo_root . '/includes/Editor/BlockNames.php';
+ require_once $repo_root . '/includes/Editor/PolicyOptions.php';
+ require_once $repo_root . '/includes/Editor/PatternGovernance.php';
+ require_once $repo_root . '/includes/Editor/AllowedBlockTypes.php';
+ require_once $repo_root . '/includes/Editor/UserPatternPermissions.php';
+ require_once $repo_root . '/includes/Editor/BlockSupportOverrides.php';
+ require_once $repo_root . '/includes/Editor/Policy.php';
+
+ $policy = new Emulsify\Theme\Editor\Policy();
+ $policy->register();
+
+ foreach ( array( 'allowed_block_types_all', 'block_editor_settings_all', 'register_post_type_args', 'block_type_metadata_settings', 'register_block_type_args' ) as $hook ) {
+ emulsify_editor_policy_smoke_assert(
+ isset( $GLOBALS['emulsify_editor_policy_smoke_filters'][ $hook ] ),
+ sprintf( 'Editor policy should register the %s hook.', $hook )
+ );
+ }
+
+ emulsify_editor_policy_smoke_assert(
+ true === apply_filters( 'allowed_block_types_all', true, $context ),
+ 'Editor policy should preserve default allowed block behavior when unconfigured.'
+ );
+
+ $default_settings = array(
+ 'blockPatterns' => array(
+ array(
+ 'name' => 'core/query',
+ ),
+ ),
+ 'enableUserPatterns' => true,
+ );
+
+ emulsify_editor_policy_smoke_assert(
+ $default_settings === apply_filters( 'block_editor_settings_all', $default_settings, $context ),
+ 'Editor policy should preserve editor settings when unconfigured.'
+ );
+
+ emulsify_editor_policy_smoke_assert(
+ array( 'capabilities' => array( 'create_posts' => 'edit_posts' ) ) === apply_filters(
+ 'register_post_type_args',
+ array(
+ 'capabilities' => array(
+ 'create_posts' => 'edit_posts',
+ ),
+ ),
+ 'wp_block'
+ ),
+ 'Editor policy should preserve wp_block capabilities when unconfigured.'
+ );
+
+ emulsify_editor_policy_smoke_write(
+ $child . '/patterns/hero.json',
+ (string) json_encode(
+ array(
+ 'content' => 'Intro
',
+ )
+ )
+ );
+
+ add_filter(
+ 'emulsify_theme_editor_policy_options',
+ static function ( array $options ) use ( $child ): array {
+ $options['allowed_block_types'] = array(
+ 'default' => array( 'core/paragraph', 'heading' ),
+ 'by_post_type' => array(
+ 'page' => array( 'core/image' ),
+ ),
+ );
+ $options['auto_allow_pattern_blocks'] = true;
+ $options['pattern_directories'] = array( $child . '/patterns' );
+ $options['pattern_namespaces'] = array( 'project', 'theme' );
+ $options['disable_user_patterns_for_non_admins'] = true;
+ $options['admin_capability'] = 'manage_options';
+ $options['restrict_wp_block_creation'] = true;
+ $options['wp_block_create_capability'] = 'manage_options';
+ $options['block_support_overrides'] = array(
+ '*' => array(
+ 'supports' => array(
+ 'styles' => false,
+ ),
+ ),
+ 'core/button' => array(
+ 'styles' => array(),
+ 'supports' => array(
+ 'color' => array(
+ 'gradients' => false,
+ ),
+ ),
+ ),
+ );
+
+ return $options;
+ }
+ );
+
+ add_filter(
+ 'emulsify_theme_allowed_block_types',
+ static function ( ?array $blocks, string $post_type ): ?array {
+ if ( 'page' === $post_type && is_array( $blocks ) ) {
+ $blocks[] = 'core/quote';
+ }
+
+ return $blocks;
+ },
+ 10,
+ 2
+ );
+
+ add_filter(
+ 'emulsify_theme_pattern_namespaces',
+ static function ( array $namespaces ): array {
+ $namespaces[] = 'child';
+
+ return $namespaces;
+ }
+ );
+
+ add_filter(
+ 'emulsify_theme_block_support_overrides',
+ static function ( array $overrides, string $block_name, array $settings, string $source ): array {
+ if ( 'core/button' === $block_name && 'register_block_type_args' === $source ) {
+ $overrides['core/button']['supports']['anchor'] = false;
+ }
+
+ return $overrides;
+ },
+ 10,
+ 4
+ );
+
+ $allowed = apply_filters( 'allowed_block_types_all', array( 'core/html' ), $context );
+
+ foreach ( array( 'core/html', 'core/paragraph', 'core/heading', 'core/image', 'core/quote', 'acf/project-hero' ) as $expected_block ) {
+ emulsify_editor_policy_smoke_assert(
+ in_array( $expected_block, $allowed, true ),
+ sprintf( 'Allowed block policy should include %s.', $expected_block )
+ );
+ }
+
+ emulsify_editor_policy_smoke_assert(
+ 1 === count( array_keys( $allowed, 'core/paragraph', true ) ),
+ 'Allowed block policy should deduplicate configured and pattern-derived block names.'
+ );
+
+ $filtered_settings = apply_filters(
+ 'block_editor_settings_all',
+ array(
+ 'blockPatterns' => array(
+ array(
+ 'name' => 'project/hero',
+ ),
+ array(
+ 'name' => 'theme/card',
+ ),
+ array(
+ 'name' => 'child/banner',
+ ),
+ array(
+ 'name' => 'core/query',
+ ),
+ ),
+ 'enableUserPatterns' => true,
+ ),
+ $context
+ );
+ $pattern_names = array_column( $filtered_settings['blockPatterns'], 'name' );
+
+ emulsify_editor_policy_smoke_assert(
+ array( 'project/hero', 'theme/card', 'child/banner' ) === $pattern_names,
+ 'Pattern namespace policy should keep only configured namespaces.'
+ );
+ emulsify_editor_policy_smoke_assert(
+ false === $filtered_settings['enableUserPatterns'],
+ 'Editor policy should disable user-created patterns for users without the admin capability.'
+ );
+
+ $wp_block_args = apply_filters( 'register_post_type_args', array( 'capabilities' => array( 'edit_posts' => 'edit_posts' ) ), 'wp_block' );
+
+ emulsify_editor_policy_smoke_assert(
+ true === $wp_block_args['map_meta_cap'] && 'manage_options' === $wp_block_args['capabilities']['create_posts'],
+ 'Editor policy should restrict wp_block creation to the configured capability.'
+ );
+ emulsify_editor_policy_smoke_assert(
+ array() === apply_filters( 'register_post_type_args', array(), 'post' ),
+ 'Editor policy should leave non-wp_block post type arguments alone.'
+ );
+
+ $metadata_settings = apply_filters(
+ 'block_type_metadata_settings',
+ array(
+ 'supports' => array(
+ 'spacing' => true,
+ ),
+ ),
+ array(
+ 'name' => 'core/button',
+ )
+ );
+
+ emulsify_editor_policy_smoke_assert(
+ false === $metadata_settings['supports']['styles']
+ && true === $metadata_settings['supports']['spacing']
+ && false === $metadata_settings['supports']['color']['gradients']
+ && array() === $metadata_settings['styles'],
+ 'Block support overrides should apply to metadata settings without dropping existing supports.'
+ );
+
+ $registration_args = apply_filters(
+ 'register_block_type_args',
+ array(
+ 'supports' => array(
+ 'anchor' => true,
+ ),
+ ),
+ 'core/button'
+ );
+
+ emulsify_editor_policy_smoke_assert(
+ false === $registration_args['supports']['styles']
+ && false === $registration_args['supports']['anchor']
+ && false === $registration_args['supports']['color']['gradients'],
+ 'Block support overrides should apply to register_block_type_args and honor source-aware filters.'
+ );
+
+ echo "Editor policy smoke checks passed.\n";
+} catch ( Throwable $throwable ) {
+ fwrite( STDERR, $throwable->getMessage() . "\n" );
+ exit( 1 );
+} finally {
+ emulsify_editor_policy_smoke_remove( $work_root );
+}
diff --git a/.github/scripts/pattern-registry-smoke.php b/.github/scripts/pattern-registry-smoke.php
new file mode 100644
index 0000000..f85dbb1
--- /dev/null
+++ b/.github/scripts/pattern-registry-smoke.php
@@ -0,0 +1,375 @@
+ $accepted_args,
+ 'callback' => $callback,
+ );
+
+ ksort( $GLOBALS['emulsify_pattern_smoke_actions'][ $hook ] );
+
+ return true;
+ }
+}
+
+if ( ! function_exists( 'do_action' ) ) {
+ function do_action( string $hook, ...$arguments ): void {
+ if ( empty( $GLOBALS['emulsify_pattern_smoke_actions'][ $hook ] ) ) {
+ return;
+ }
+
+ foreach ( $GLOBALS['emulsify_pattern_smoke_actions'][ $hook ] as $callbacks ) {
+ foreach ( $callbacks as $callback ) {
+ call_user_func_array(
+ $callback['callback'],
+ array_slice( $arguments, 0, $callback['accepted_args'] )
+ );
+ }
+ }
+ }
+}
+
+if ( ! function_exists( 'add_filter' ) ) {
+ function add_filter( string $hook, callable $callback, int $priority = 10, int $accepted_args = 1 ): bool {
+ $GLOBALS['emulsify_pattern_smoke_filters'][ $hook ][ $priority ][] = array(
+ 'accepted_args' => $accepted_args,
+ 'callback' => $callback,
+ );
+
+ ksort( $GLOBALS['emulsify_pattern_smoke_filters'][ $hook ] );
+
+ return true;
+ }
+}
+
+if ( ! function_exists( 'apply_filters' ) ) {
+ function apply_filters( string $hook, $value, ...$arguments ) {
+ if ( empty( $GLOBALS['emulsify_pattern_smoke_filters'][ $hook ] ) ) {
+ return $value;
+ }
+
+ foreach ( $GLOBALS['emulsify_pattern_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_pattern_smoke_child'];
+ }
+}
+
+if ( ! function_exists( 'get_template_directory' ) ) {
+ function get_template_directory(): string {
+ return $GLOBALS['emulsify_pattern_smoke_parent'];
+ }
+}
+
+if ( ! function_exists( 'register_block_pattern' ) ) {
+ function register_block_pattern( string $name, array $args ): bool {
+ $GLOBALS['emulsify_pattern_smoke_patterns'][ $name ] = $args;
+
+ return true;
+ }
+}
+
+if ( ! function_exists( 'register_block_pattern_category' ) ) {
+ function register_block_pattern_category( string $name, array $args ): bool {
+ $GLOBALS['emulsify_pattern_smoke_categories'][ $name ] = $args;
+
+ 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_pattern_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_pattern_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_pattern_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-pattern-registry-' . uniqid( '', true );
+$child = $work_root . '/child-theme';
+$parent = $work_root . '/parent-theme';
+$extra = $work_root . '/extra-patterns';
+
+$GLOBALS['emulsify_pattern_smoke_child'] = $child;
+$GLOBALS['emulsify_pattern_smoke_parent'] = $parent;
+
+try {
+ require_once $repo_root . '/includes/Support/FileDiscovery.php';
+ require_once $repo_root . '/includes/Blocks/Patterns.php';
+
+ $patterns = new Emulsify\Theme\Blocks\Patterns();
+ $patterns->register();
+
+ emulsify_pattern_smoke_assert(
+ isset( $GLOBALS['emulsify_pattern_smoke_actions']['init'] ),
+ 'Patterns service should register on init.'
+ );
+
+ do_action( 'init' );
+
+ emulsify_pattern_smoke_assert(
+ array() === $GLOBALS['emulsify_pattern_smoke_patterns'],
+ 'Patterns service should no-op when no pattern directories exist.'
+ );
+
+ emulsify_pattern_smoke_write(
+ $child . '/patterns/hero.json',
+ (string) json_encode(
+ array(
+ 'name' => 'child/hero',
+ 'title' => 'Child Hero',
+ 'description' => 'A child pattern.',
+ 'categories' => array( 'starter' ),
+ 'keywords' => array( 'hero' ),
+ 'postTypes' => array( 'page' ),
+ 'viewportWidth' => 1200,
+ 'content' => 'Child hero
',
+ )
+ )
+ );
+ emulsify_pattern_smoke_write(
+ $parent . '/patterns/categories.json',
+ (string) json_encode(
+ array(
+ 'starter' => array(
+ 'label' => 'Parent Starter',
+ 'description' => 'Parent starter patterns.',
+ ),
+ )
+ )
+ );
+ emulsify_pattern_smoke_write(
+ $child . '/patterns/categories.json',
+ (string) json_encode(
+ array(
+ 'name' => 'child/categories',
+ 'title' => 'Category Metadata Should Not Register',
+ 'content' => 'Not a block pattern.
',
+ 'starter' => array(
+ 'label' => 'Child Starter',
+ ),
+ )
+ )
+ );
+ emulsify_pattern_smoke_write(
+ $child . '/patterns/override.json',
+ (string) json_encode(
+ array(
+ 'name' => 'child/override',
+ 'title' => 'Child Override',
+ 'content' => 'Child override
',
+ )
+ )
+ );
+ emulsify_pattern_smoke_write(
+ $parent . '/patterns/override.json',
+ (string) json_encode(
+ array(
+ 'name' => 'parent/override',
+ 'title' => 'Parent Override',
+ 'content' => 'Parent override
',
+ )
+ )
+ );
+ emulsify_pattern_smoke_write(
+ $parent . '/patterns/invalid.json',
+ (string) json_encode(
+ array(
+ 'name' => 'parent/invalid',
+ 'title' => 'Invalid',
+ )
+ )
+ );
+ emulsify_pattern_smoke_write(
+ $extra . '/extra.json',
+ (string) json_encode(
+ array(
+ 'name' => 'extra/promo',
+ 'title' => 'Extra Promo',
+ 'categories' => array( 'extra' ),
+ 'content' => 'Extra promo
',
+ )
+ )
+ );
+
+ add_filter(
+ 'emulsify_theme_pattern_directories',
+ static function ( array $directories ) use ( $extra ): array {
+ $directories[] = array(
+ 'path' => $extra,
+ 'source' => 'smoke',
+ );
+
+ return $directories;
+ }
+ );
+
+ add_filter(
+ 'emulsify_theme_pattern_data',
+ static function ( array $data, array $file ): array {
+ if ( 'hero.json' === $file['relative'] ) {
+ $data['title'] = 'Filtered Hero';
+ }
+
+ return $data;
+ },
+ 10,
+ 2
+ );
+
+ add_filter(
+ 'emulsify_theme_pattern_categories',
+ static function ( array $categories ): array {
+ $categories['starter']['description'] = 'Starter category from smoke coverage.';
+
+ return $categories;
+ }
+ );
+
+ add_filter(
+ 'emulsify_theme_pattern_args',
+ static function ( array $args, array $data ): array {
+ if ( 'extra/promo' === $args['name'] ) {
+ $args['keywords'][] = 'filtered';
+ }
+
+ return $args;
+ },
+ 10,
+ 2
+ );
+
+ $GLOBALS['emulsify_pattern_smoke_patterns'] = array();
+ $GLOBALS['emulsify_pattern_smoke_categories'] = array();
+
+ $patterns->register_patterns();
+
+ emulsify_pattern_smoke_assert(
+ array( 'child/hero', 'child/override', 'extra/promo' ) === array_keys( $GLOBALS['emulsify_pattern_smoke_patterns'] ),
+ 'Patterns service should register valid child-first and filtered JSON patterns without registering category metadata files.'
+ );
+ emulsify_pattern_smoke_assert(
+ ! isset( $GLOBALS['emulsify_pattern_smoke_patterns']['parent/override'] ),
+ 'Child pattern JSON files should override parent files with the same basename.'
+ );
+ emulsify_pattern_smoke_assert(
+ 'Filtered Hero' === $GLOBALS['emulsify_pattern_smoke_patterns']['child/hero']['title'],
+ 'Decoded pattern data filter should alter metadata before registration.'
+ );
+ emulsify_pattern_smoke_assert(
+ 1200 === $GLOBALS['emulsify_pattern_smoke_patterns']['child/hero']['viewportWidth'],
+ 'Patterns service should preserve viewportWidth metadata.'
+ );
+ emulsify_pattern_smoke_assert(
+ array( 'starter', 'extra' ) === array_keys( $GLOBALS['emulsify_pattern_smoke_categories'] ),
+ 'Patterns service should register categories discovered from valid pattern metadata.'
+ );
+ emulsify_pattern_smoke_assert(
+ 'Child Starter' === $GLOBALS['emulsify_pattern_smoke_categories']['starter']['label'],
+ 'Child pattern category metadata should override parent category labels.'
+ );
+ emulsify_pattern_smoke_assert(
+ 'Starter category from smoke coverage.' === $GLOBALS['emulsify_pattern_smoke_categories']['starter']['description'],
+ 'Pattern categories filter should alter category registration args.'
+ );
+ emulsify_pattern_smoke_assert(
+ 'Extra' === $GLOBALS['emulsify_pattern_smoke_categories']['extra']['label'],
+ 'Pattern categories without metadata should fall back to a readable slug label.'
+ );
+ emulsify_pattern_smoke_assert(
+ in_array( 'filtered', $GLOBALS['emulsify_pattern_smoke_patterns']['extra/promo']['keywords'], true ),
+ 'Pattern args filter should alter final registration args.'
+ );
+
+ echo "Pattern registry smoke checks passed.\n";
+} catch ( Throwable $throwable ) {
+ fwrite( STDERR, $throwable->getMessage() . "\n" );
+ exit( 1 );
+} finally {
+ emulsify_pattern_smoke_remove( $work_root );
+}
diff --git a/.github/scripts/pr-validation.cjs b/.github/scripts/pr-validation.cjs
new file mode 100644
index 0000000..49ff02b
--- /dev/null
+++ b/.github/scripts/pr-validation.cjs
@@ -0,0 +1,48 @@
+#!/usr/bin/env node
+
+// Practical PR gate for checks that do not need a database-backed WordPress
+// install. The Whisk starter is installed but not built because a generated
+// child theme intentionally fails Vite until a component system is installed.
+
+const childProcess = require('child_process');
+const path = require('path');
+
+const repoRoot = path.resolve(__dirname, '../..');
+
+function run(label, command, args) {
+ console.log(`\n[pr-validation] ${label}`);
+ const result = childProcess.spawnSync(command, args, {
+ cwd: repoRoot,
+ env: process.env,
+ stdio: 'inherit',
+ });
+
+ if (result.status !== 0) {
+ process.exit(result.status || 1);
+ }
+}
+
+run('Validate Composer metadata', 'composer', ['validate', '--no-check-publish', '--strict']);
+run('Install Composer dependencies for PHP lint and Twig smoke coverage', 'composer', [
+ 'install',
+ '--no-interaction',
+ '--no-progress',
+ '--prefer-dist',
+]);
+run('Run Bootstrap loader smoke test', 'npm', ['run', 'smoke:bootstrap-loader']);
+run('Run PHP lint', 'npm', ['run', 'lint:php']);
+run('Run ACF Local JSON smoke test', 'npm', ['run', 'smoke:acf-json']);
+run('Run asset manifest smoke test', 'npm', ['run', 'smoke:asset-manifest']);
+run('Run Twig attribute helper smoke test', 'npm', ['run', 'smoke:attributes']);
+run('Run block scoped asset smoke test', 'npm', ['run', 'smoke:block-assets']);
+run('Run child theme generator smoke test', 'npm', ['run', 'smoke:child-theme-generator']);
+run('Run component locator smoke test', 'npm', ['run', 'smoke:component-locator']);
+run('Run core block Twig renderer smoke test', 'npm', ['run', 'smoke:core-block-twig']);
+run('Run editor enhancements smoke test', 'npm', ['run', 'smoke:editor-enhancements']);
+run('Run editor policy smoke test', 'npm', ['run', 'smoke:editor-policy']);
+run('Run pattern registry smoke test', 'npm', ['run', 'smoke:patterns']);
+run('Run parent theme filter smoke test', 'npm', ['run', 'smoke:theme-filters']);
+run('Run Twig switch smoke test', 'npm', ['run', 'smoke:twig-switch']);
+run('Run Twig project namespace smoke test', 'npm', ['run', 'smoke:twig-project-namespace']);
+run('Install Whisk dependencies', 'npm', ['run', 'whisk:install']);
+run('Run WordPress starter init smoke test', 'npm', ['run', 'smoke:starter-init']);
diff --git a/.github/scripts/release-check.cjs b/.github/scripts/release-check.cjs
new file mode 100644
index 0000000..ed1ab08
--- /dev/null
+++ b/.github/scripts/release-check.cjs
@@ -0,0 +1,1261 @@
+#!/usr/bin/env node
+
+// Static release contract checks plus optional WordPress fixture smoke coverage.
+// This script makes release assumptions executable so broad refactors do not
+// silently drop a parent-theme hook, generated child convention, or CI guarantee.
+
+const fs = require('fs');
+const path = require('path');
+const childProcess = require('child_process');
+
+const repoRoot = path.resolve(__dirname, '../..');
+const results = [];
+
+function addResult(status, name, detail) {
+ results.push({ status, name, detail });
+}
+
+function readFile(relativePath) {
+ return fs.readFileSync(path.join(repoRoot, relativePath), 'utf8');
+}
+
+function readJson(relativePath) {
+ return JSON.parse(readFile(relativePath));
+}
+
+function ensure(condition, message) {
+ if (!condition) {
+ throw new Error(message);
+ }
+}
+
+function semver(value) {
+ return /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(String(value));
+}
+
+function runStaticCheck(name, callback) {
+ // Static checks intentionally inspect files as text. They catch packaging and
+ // documentation regressions without needing a full WordPress install.
+ try {
+ const detail = callback();
+ addResult('PASS', name, detail);
+ }
+ catch (error) {
+ addResult('FAIL', name, error.message);
+ }
+}
+
+function runCommandCheck(name, command, args) {
+ // Command checks are reserved for smoke paths that already know how to skip
+ // when local prerequisites such as WP-CLI or MySQL are absent.
+ const result = childProcess.spawnSync(command, args, {
+ cwd: repoRoot,
+ encoding: 'utf8',
+ env: process.env,
+ maxBuffer: 1024 * 1024 * 20,
+ });
+ const output = `${result.stdout || ''}${result.stderr || ''}`.trim();
+
+ if (result.status !== 0) {
+ addResult('FAIL', name, output || `${command} ${args.join(' ')} failed with exit code ${result.status}.`);
+ return;
+ }
+
+ if (output.includes('WORDPRESS_SMOKE_SKIPPED')) {
+ addResult('SKIP', name, output.split(/\r?\n/).filter(Boolean).pop());
+ return;
+ }
+
+ addResult('PASS', name, output.split(/\r?\n/).filter(Boolean).pop() || `${command} ${args.join(' ')} passed.`);
+}
+
+function listFilesRecursive(relativePath, predicate) {
+ const absolutePath = path.join(repoRoot, relativePath);
+ if (!fs.existsSync(absolutePath)) {
+ return [];
+ }
+
+ const files = [];
+ for (const entry of fs.readdirSync(absolutePath, { withFileTypes: true })) {
+ const childPath = path.join(relativePath, entry.name);
+ if (entry.isDirectory()) {
+ if (childPath === '.git') {
+ continue;
+ }
+ files.push(...listFilesRecursive(childPath, predicate));
+ }
+ else if (predicate(childPath)) {
+ files.push(childPath);
+ }
+ }
+
+ return files;
+}
+
+function extractJsonObjectSegment(text, key) {
+ const keyPattern = new RegExp(`"${key}"\\s*:\\s*\\{`);
+ const match = keyPattern.exec(text);
+ if (!match) {
+ return null;
+ }
+
+ const start = text.indexOf('{', match.index);
+ let depth = 0;
+ let inString = false;
+ let escaped = false;
+
+ for (let index = start; index < text.length; index += 1) {
+ const character = text[index];
+
+ if (inString) {
+ if (escaped) {
+ escaped = false;
+ }
+ else if (character === '\\') {
+ escaped = true;
+ }
+ else if (character === '"') {
+ inString = false;
+ }
+ continue;
+ }
+
+ if (character === '"') {
+ inString = true;
+ continue;
+ }
+
+ if (character === '{') {
+ depth += 1;
+ continue;
+ }
+
+ if (character === '}') {
+ depth -= 1;
+ if (depth === 0) {
+ return text.slice(start, index + 1);
+ }
+ }
+ }
+
+ return null;
+}
+
+function findDuplicatePackageScripts(relativePath) {
+ const raw = readFile(relativePath);
+ const scriptsBlock = extractJsonObjectSegment(raw, 'scripts');
+ ensure(scriptsBlock, `Unable to locate the scripts block in ${relativePath}.`);
+
+ const keys = [...scriptsBlock.matchAll(/^\s*"([^"]+)"\s*:/gm)].map((match) => match[1]);
+ const seen = new Set();
+ const duplicates = new Set();
+
+ for (const key of keys) {
+ if (seen.has(key)) {
+ duplicates.add(key);
+ }
+ seen.add(key);
+ }
+
+ return [...duplicates].sort();
+}
+
+function parseWordPressThemeHeader(relativePath) {
+ const header = {};
+ const contents = readFile(relativePath);
+
+ for (const line of contents.split(/\r?\n/)) {
+ const match = line.match(/^\s*\*\s*([^:]+):\s*(.*?)\s*$/);
+ if (match) {
+ header[match[1].trim()] = match[2].trim();
+ }
+ }
+
+ return header;
+}
+
+function ensureNoTitleCaseBuildPhrase(label, value) {
+ ensure(!/Webpack Build|Vite Build/.test(value), `${label} should not use title-case build workflow phrases.`);
+}
+
+const incorrectWordPressPattern = new RegExp('Word' + 'press');
+
+function ensureWordPressLanguage(label, value) {
+ ensure(value.includes('WordPress'), `${label} should use the canonical WordPress spelling.`);
+ ensure(!incorrectWordPressPattern.test(value), `${label} should use the canonical WordPress spelling.`);
+}
+
+function ensureViteLanguage(label, value) {
+ ensure(value.includes('Emulsify Core 4'), `${label} should mention Emulsify Core 4.`);
+ ensure(value.includes('Vite'), `${label} should mention Vite.`);
+ ensure(value.includes('Storybook'), `${label} should mention Storybook.`);
+ ensure(value.includes('Twig'), `${label} should mention Twig.`);
+ ensure(!/Webpack/i.test(value), `${label} should not mention Webpack.`);
+}
+
+function ensureParentThemeLanguage(label, value) {
+ ensureWordPressLanguage(label, value);
+ ensureViteLanguage(label, value);
+ ensure(value.includes('Timber-first'), `${label} should use Timber-first parent theme language.`);
+ ensure(value.includes('parent theme'), `${label} should describe the parent theme.`);
+ ensure(
+ value.includes('generated child themes') || value.includes('generates child themes'),
+ `${label} should mention generated child themes.`
+ );
+}
+
+function ensureGeneratedChildThemeLanguage(label, value) {
+ ensureWordPressLanguage(label, value);
+ ensureViteLanguage(label, value);
+ ensure(value.includes('generated') || value.includes('Generated'), `${label} should describe Whisk as generated.`);
+ ensure(value.includes('child theme'), `${label} should use child theme language.`);
+}
+
+function ensureGpl2LicenseText(label, value) {
+ ensure(value.includes('GNU GENERAL PUBLIC LICENSE'), `${label} should contain the GNU GPL license text.`);
+ ensure(value.includes('Version 2, June 1991'), `${label} should contain GPL version 2 text.`);
+ ensure(!/MIT License/i.test(value), `${label} should not contain MIT license text.`);
+}
+
+function getReleasePluginOptions(releaseConfig, pluginName) {
+ const plugin = releaseConfig.plugins.find((candidate) => {
+ if (Array.isArray(candidate)) {
+ return candidate[0] === pluginName;
+ }
+
+ return candidate === pluginName;
+ });
+
+ ensure(plugin, `release.config.js must include ${pluginName}.`);
+ return Array.isArray(plugin) ? plugin[1] || {} : {};
+}
+
+function ensureBreakingParser(label, parserOpts) {
+ ensure(parserOpts, `${label} parserOpts are required.`);
+ ensure(parserOpts.breakingHeaderPattern, `${label} must define breakingHeaderPattern.`);
+ ensure(parserOpts.breakingHeaderPattern.test('feat!: remove legacy API'), `${label} should parse feat!: as breaking.`);
+ ensure(parserOpts.breakingHeaderPattern.test('feat(theme)!: remove legacy API'), `${label} should parse feat(scope)!: as breaking.`);
+ ensure(parserOpts.noteKeywords.includes('BREAKING CHANGE'), `${label} should parse BREAKING CHANGE notes.`);
+ ensure(parserOpts.noteKeywords.includes('BREAKING CHANGES'), `${label} should parse BREAKING CHANGES notes.`);
+}
+
+function runStaticChecks() {
+ const rootPackage = readJson('package.json');
+ const whiskPackage = readJson('whisk/package.json');
+ const composer = readJson('composer.json');
+ const rootThemeHeader = parseWordPressThemeHeader('style.css');
+ const whiskThemeHeader = parseWordPressThemeHeader('whisk/style.css');
+ const whiskProject = readJson('whisk/project.emulsify.json');
+ const releaseConfig = require(path.join(repoRoot, 'release.config.js'));
+ const semanticReleaseWorkflow = readFile('.github/workflows/semantic-release.yml');
+ const themeReadinessWorkflow = readFile('.github/workflows/theme-readiness.yml');
+ const starterInitSmoke = readFile('.github/scripts/wordpress-starter-init-smoke.cjs');
+ const wordpressFixtureSmoke = readFile('.github/scripts/wordpress-fixture-smoke.cjs');
+ const readme = readFile('README.md');
+ const docs = {
+ index: readFile('docs/README.md'),
+ upgrading: readFile('docs/upgrading-1x-to-2x.md'),
+ parity: readFile('docs/sister-project-parity.md'),
+ architecture: readFile('docs/parent-child-architecture.md'),
+ twig: readFile('docs/timber-and-twig-authoring.md'),
+ workflow: readFile('docs/core-4-vite-workflow.md'),
+ componentRecipes: readFile('docs/component-recipes.md'),
+ acfJson: readFile('docs/acf-local-json.md'),
+ acfBlocks: readFile('docs/acf-twig-blocks.md'),
+ coreBlockTwig: readFile('docs/core-block-twig-rendering.md'),
+ nativeBlocks: readFile('docs/native-gutenberg-blocks.md'),
+ blockPatterns: readFile('docs/block-patterns.md'),
+ editorEnhancements: readFile('docs/editor-enhancements.md'),
+ editorPolicy: readFile('docs/editor-policy.md'),
+ assets: readFile('docs/asset-loading.md'),
+ cli: readFile('docs/wp-cli-child-theme-generation.md'),
+ release: readFile('docs/release-process.md'),
+ post2xRoadmap: readFile('docs/post-2x-optimization-roadmap.md'),
+ };
+ const docsText = Object.values(docs).join('\n');
+ const license = readFile('LICENSE');
+ const issueTemplate = readFile('.github/ISSUE_TEMPLATE.md');
+ const pullRequestTemplate = readFile('.github/PULL_REQUEST_TEMPLATE.md');
+ const releaseGuardRejectContext = {
+ branch: { name: 'main' },
+ lastRelease: { version: '1.0.0' },
+ nextRelease: { version: '1.1.0' },
+ };
+ const releaseGuardAcceptContext = {
+ branch: { name: 'main' },
+ lastRelease: { version: '1.0.0' },
+ nextRelease: { version: '2.0.0' },
+ };
+ const releaseGuardFutureContext = {
+ branch: { name: 'main' },
+ lastRelease: { version: '2.0.0' },
+ nextRelease: { version: '2.0.1' },
+ };
+ const releaseAnalyzerAlphaContext = {
+ branch: { name: 'main' },
+ lastRelease: { version: '1.0.0-alpha.5' },
+ };
+
+ runStaticCheck('Required files', () => {
+ const requiredFiles = [
+ '.github/scripts/release-check.cjs',
+ '.github/scripts/pr-validation.cjs',
+ '.github/workflows/semantic-release.yml',
+ '.github/workflows/theme-readiness.yml',
+ '.gitignore',
+ '.nvmrc',
+ 'README.md',
+ 'docs/README.md',
+ 'docs/acf-local-json.md',
+ 'docs/acf-twig-blocks.md',
+ 'docs/asset-loading.md',
+ 'docs/block-patterns.md',
+ 'docs/component-recipes.md',
+ 'docs/core-block-twig-rendering.md',
+ 'docs/core-4-vite-workflow.md',
+ 'docs/editor-enhancements.md',
+ 'docs/native-gutenberg-blocks.md',
+ 'docs/editor-policy.md',
+ 'docs/parent-child-architecture.md',
+ 'docs/post-2x-optimization-roadmap.md',
+ 'docs/release-process.md',
+ 'docs/sister-project-parity.md',
+ 'docs/timber-and-twig-authoring.md',
+ 'docs/upgrading-1x-to-2x.md',
+ 'docs/wp-cli-child-theme-generation.md',
+ 'composer.json',
+ 'phpcs.xml.dist',
+ 'phpstan.neon.dist',
+ 'functions.php',
+ 'includes/Bootstrap.php',
+ 'includes/Acf/LocalJson.php',
+ 'includes/Blocks/AcfBlocks.php',
+ 'includes/Blocks/ComponentLocator.php',
+ 'includes/Blocks/CoreBlockTwigRenderer.php',
+ 'includes/Blocks/NativeBlocks.php',
+ 'includes/Blocks/Patterns.php',
+ 'includes/Blocks/Registry.php',
+ 'includes/Cli/GenerateChildThemeCommand.php',
+ 'includes/Editor/AllowedBlockTypes.php',
+ 'includes/Editor/BlockNames.php',
+ 'includes/Editor/BlockSupportOverrides.php',
+ 'includes/Editor/Enhancements.php',
+ 'includes/Editor/PatternGovernance.php',
+ 'includes/Editor/Policy.php',
+ 'includes/Editor/PolicyOptions.php',
+ 'includes/Editor/UserPatternPermissions.php',
+ 'includes/Runtime/Assets.php',
+ 'includes/Runtime/Context.php',
+ 'includes/Runtime/MissingTimber.php',
+ 'includes/Runtime/Setup.php',
+ 'includes/Runtime/TimberIntegration.php',
+ 'includes/Runtime/Twig.php',
+ 'includes/Support/AssetManifest.php',
+ 'includes/Support/AttributeBag.php',
+ 'includes/Support/FileDiscovery.php',
+ 'includes/Twig/SwitchExtension.php',
+ 'includes/Twig/SwitchNode.php',
+ 'includes/Twig/SwitchTokenParser.php',
+ 'package.json',
+ 'release.config.js',
+ 'style.css',
+ 'templates/404.twig',
+ 'templates/archive.twig',
+ 'templates/author.twig',
+ 'templates/index.twig',
+ 'templates/page.twig',
+ 'templates/search.twig',
+ 'templates/single-password.twig',
+ 'templates/single.twig',
+ '.github/scripts/acf-local-json-smoke.php',
+ '.github/scripts/asset-manifest-smoke.php',
+ '.github/scripts/attribute-helper-smoke.php',
+ '.github/scripts/block-scoped-assets-smoke.php',
+ '.github/scripts/bootstrap-loader-smoke.php',
+ '.github/scripts/child-theme-generator-smoke.php',
+ '.github/scripts/component-locator-smoke.php',
+ '.github/scripts/editor-enhancements-smoke.php',
+ '.github/scripts/editor-policy-smoke.php',
+ '.github/scripts/pattern-registry-smoke.php',
+ '.github/scripts/wordpress-starter-init-smoke.cjs',
+ '.github/scripts/theme-filters-smoke.php',
+ '.github/scripts/twig-project-namespace-smoke.php',
+ '.github/scripts/twig-switch-smoke.php',
+ '.github/scripts/wordpress-fixture-smoke.cjs',
+ 'whisk/.cli/init.js',
+ 'whisk/.gitignore',
+ 'whisk/.nvmrc',
+ 'whisk/config/jest.config.js',
+ 'whisk/functions.php',
+ 'whisk/package.json',
+ 'whisk/project.emulsify.json',
+ 'whisk/style.css',
+ 'whisk/config/acf-json/.gitkeep',
+ 'whisk/patterns/.gitkeep',
+ 'whisk/src/components/.gitkeep',
+ 'whisk/templates/page.twig',
+ ];
+ const missingFiles = requiredFiles.filter((file) => !fs.existsSync(path.join(repoRoot, file)));
+ ensure(missingFiles.length === 0, `Missing required files: ${missingFiles.join(', ')}.`);
+ return `Found ${requiredFiles.length} required release files.`;
+ });
+
+ runStaticCheck('Root release metadata', () => {
+ ensure(rootPackage.name === 'emulsify-wordpress', 'package.json name should be emulsify-wordpress.');
+ ensure(semver(rootPackage.version), 'package.json version must be a valid semver string.');
+ ensure(rootPackage.version === '2.0.0', 'package.json version should prepare the 2.0.0 release.');
+ ensure(rootPackage.description, 'package.json description is required.');
+ ensureParentThemeLanguage('package.json description', rootPackage.description);
+ ensureNoTitleCaseBuildPhrase('package.json description', rootPackage.description);
+ ensure(rootPackage.license === 'GPL-2.0-only', 'package.json license should be GPL-2.0-only.');
+ ensure(rootPackage.engines && rootPackage.engines.node === '>=24.10', 'package.json engines.node should be >=24.10.');
+ ensure(rootPackage.repository.url === 'git+https://github.com/emulsify-ds/emulsify-wordpress.git', 'package.json repository.url should target emulsify-wordpress.');
+ ensure(rootPackage.bugs.url === 'https://github.com/emulsify-ds/emulsify-wordpress/issues', 'package.json bugs.url should target emulsify-wordpress.');
+ ensure(rootPackage.scripts['pr:check'] === 'node .github/scripts/pr-validation.cjs', 'package.json should expose npm run pr:check.');
+ ensure(rootPackage.scripts['release:check'] === 'node .github/scripts/release-check.cjs', 'package.json should expose npm run release:check.');
+ ensure(rootPackage.scripts['lint:php'].includes('vendor/bin/phpcs -q'), 'package.json lint:php should run PHPCS.');
+ ensure(rootPackage.scripts['lint:php'].includes('vendor/bin/phpstan analyse --no-progress'), 'package.json lint:php should run PHPStan.');
+ ensure(rootPackage.scripts['lint:php:fix'] === 'vendor/bin/phpcbf', 'package.json should expose npm run lint:php:fix.');
+ ensure(rootPackage.scripts['smoke:acf-json'] === 'php .github/scripts/acf-local-json-smoke.php', 'package.json should expose npm run smoke:acf-json.');
+ ensure(rootPackage.scripts['smoke:asset-manifest'] === 'php .github/scripts/asset-manifest-smoke.php', 'package.json should expose npm run smoke:asset-manifest.');
+ ensure(rootPackage.scripts['smoke:attributes'] === 'php .github/scripts/attribute-helper-smoke.php', 'package.json should expose npm run smoke:attributes.');
+ ensure(rootPackage.scripts['smoke:block-assets'] === 'php .github/scripts/block-scoped-assets-smoke.php', 'package.json should expose npm run smoke:block-assets.');
+ ensure(rootPackage.scripts['smoke:bootstrap-loader'] === 'php .github/scripts/bootstrap-loader-smoke.php', 'package.json should expose npm run smoke:bootstrap-loader.');
+ ensure(rootPackage.scripts['smoke:child-theme-generator'] === 'php .github/scripts/child-theme-generator-smoke.php', 'package.json should expose npm run smoke:child-theme-generator.');
+ ensure(rootPackage.scripts['smoke:component-locator'] === 'php .github/scripts/component-locator-smoke.php', 'package.json should expose npm run smoke:component-locator.');
+ ensure(rootPackage.scripts['smoke:editor-enhancements'] === 'php .github/scripts/editor-enhancements-smoke.php', 'package.json should expose npm run smoke:editor-enhancements.');
+ ensure(rootPackage.scripts['smoke:editor-policy'] === 'php .github/scripts/editor-policy-smoke.php', 'package.json should expose npm run smoke:editor-policy.');
+ ensure(rootPackage.scripts['smoke:patterns'] === 'php .github/scripts/pattern-registry-smoke.php', 'package.json should expose npm run smoke:patterns.');
+ ensure(rootPackage.scripts['smoke:starter-init'] === 'node .github/scripts/wordpress-starter-init-smoke.cjs', 'package.json should expose npm run smoke:starter-init.');
+ ensure(rootPackage.scripts['smoke:theme-filters'] === 'php .github/scripts/theme-filters-smoke.php', 'package.json should expose npm run smoke:theme-filters.');
+ ensure(rootPackage.scripts['smoke:twig-switch'] === 'php .github/scripts/twig-switch-smoke.php', 'package.json should expose npm run smoke:twig-switch.');
+ ensure(rootPackage.scripts['smoke:twig-project-namespace'] === 'php .github/scripts/twig-project-namespace-smoke.php', 'package.json should expose npm run smoke:twig-project-namespace.');
+ ensure(rootPackage.scripts['whisk:install'], 'package.json should expose npm run whisk:install.');
+ ensure(rootPackage.scripts['whisk:build'] === 'npm --prefix whisk run build', 'package.json should expose npm run whisk:build.');
+ ensure(!rootPackage.devDependencies['@semantic-release/changelog'], 'package.json should not declare unused @semantic-release/changelog tooling.');
+ ensure(!rootPackage.devDependencies['@semantic-release/git'], 'package.json should not declare unused @semantic-release/git tooling.');
+ ensure(!rootPackage.devDependencies['@semantic-release/npm'], 'package.json should not declare unused @semantic-release/npm tooling.');
+ ensure(!Object.hasOwn(rootPackage, 'overrides'), 'package.json should not need semantic-release npm overrides.');
+ ensure(composer.name === 'emulsify-ds/emulsify-wordpress', 'composer.json name should be emulsify-ds/emulsify-wordpress.');
+ ensure(composer.type === 'wordpress-theme', 'composer.json type should be wordpress-theme.');
+ ensure(composer.license === 'GPL-2.0-only', 'composer.json license should be GPL-2.0-only.');
+ ensure(composer.homepage === 'https://www.emulsify.info', 'composer.json homepage should use the canonical HTTPS URL.');
+ ensure(!Object.hasOwn(composer, 'minimum-stability'), 'composer.json should not lower release stability for a stable parent theme.');
+ ensure(!Object.hasOwn(composer, 'prefer-stable'), 'composer.json should not keep prefer-stable when stable-only constraints are sufficient.');
+ ensure(composer.require && typeof composer.require.php === 'string' && composer.require.php.startsWith('>=8.3'), 'composer.json should enforce the PHP 8.3 runtime floor.');
+ ensure(composer.require && composer.require['timber/timber'] === '^2.3', 'composer.json should keep the Timber 2 dependency constraint.');
+ ensure(composer['require-dev'] && composer['require-dev']['wp-coding-standards/wpcs'], 'composer.json should provide WordPress Coding Standards for PHP linting.');
+ ensure(composer['require-dev'] && composer['require-dev']['phpstan/phpstan'], 'composer.json should provide PHPStan for static analysis.');
+ ensure(composer.config && composer.config['allow-plugins'] && composer.config['allow-plugins']['dealerdirect/phpcodesniffer-composer-installer'], 'composer.json should allow the PHPCS standards installer plugin.');
+ ensure(composer.autoload && composer.autoload['psr-4'] && composer.autoload['psr-4']['Emulsify\\Theme\\'] === 'includes/', 'composer.json should expose the runtime namespace through PSR-4 autoloading.');
+ ensure(!Object.hasOwn(composer.autoload, 'classmap'), 'composer.json should rely on PSR-4 runtime paths instead of classmap loading.');
+ ensure(!Object.hasOwn(composer.autoload, 'files'), 'composer.json should not load removed compatibility files.');
+ ensureParentThemeLanguage('composer.json description', composer.description);
+ return `Validated root package ${rootPackage.version} and composer metadata.`;
+ });
+
+ runStaticCheck('WordPress theme headers', () => {
+ ensure(rootThemeHeader['Theme Name'] === 'Emulsify', 'style.css Theme Name should be Emulsify.');
+ ensure(rootThemeHeader['Text Domain'] === 'emulsify', 'style.css Text Domain should be emulsify.');
+ ensure(rootThemeHeader.Version === rootPackage.version, 'style.css Version should match package.json version.');
+ ensure(rootThemeHeader.Version === '2.0.0', 'style.css Version should prepare the 2.0.0 release.');
+ ensure(rootThemeHeader.License === 'GPL-2.0-only', 'style.css License should be GPL-2.0-only.');
+ ensure(rootThemeHeader['License URI'] === 'https://www.gnu.org/licenses/old-licenses/gpl-2.0.html', 'style.css License URI should point to GPLv2.');
+ ensure(rootThemeHeader['Requires at least'] === '6.7', 'style.css Requires at least should stay aligned to the WordPress baseline.');
+ ensure(rootThemeHeader['Tested up to'] === '6.7', 'style.css Tested up to should stay aligned to the WordPress baseline.');
+ ensure(rootThemeHeader['Requires PHP'] === '8.3', 'style.css Requires PHP should stay aligned to the release baseline.');
+ ensureParentThemeLanguage('style.css Description', rootThemeHeader.Description);
+ ensure(whiskThemeHeader['Theme Name'] === 'Whisk', 'whisk/style.css Theme Name should be Whisk.');
+ ensure(whiskThemeHeader.Template === 'emulsify', 'whisk/style.css Template should be emulsify.');
+ ensure(whiskThemeHeader['Text Domain'] === 'whisk', 'whisk/style.css Text Domain should be whisk.');
+ ensure(whiskThemeHeader.Version === whiskPackage.version, 'whisk/style.css Version should match whisk/package.json version.');
+ ensure(whiskThemeHeader.Version === '2.0.0', 'whisk/style.css Version should prepare the 2.0.0 release.');
+ ensure(whiskThemeHeader.License === 'GPL-2.0-only', 'whisk/style.css License should be GPL-2.0-only.');
+ ensure(whiskThemeHeader['License URI'] === 'https://www.gnu.org/licenses/old-licenses/gpl-2.0.html', 'whisk/style.css License URI should point to GPLv2.');
+ ensureGeneratedChildThemeLanguage('whisk/style.css Description', whiskThemeHeader.Description);
+ return 'Parent and Whisk WordPress theme headers are coherent with package metadata.';
+ });
+
+ runStaticCheck('Timber attribute helpers', () => {
+ const bootstrap = readFile('includes/Bootstrap.php');
+ const twig = readFile('includes/Runtime/Twig.php');
+ const attributeBag = readFile('includes/Support/AttributeBag.php');
+ const smoke = readFile('.github/scripts/attribute-helper-smoke.php');
+
+ ensure(bootstrap.includes('load_vendor_autoload();') && bootstrap.includes('load_classes();'), 'Bootstrap should load Composer before registering fallback runtime loading.');
+ ensure(bootstrap.indexOf('load_vendor_autoload();') < bootstrap.indexOf('load_classes();'), 'Bootstrap should try Composer autoloading before fallback runtime loading.');
+ ensure(bootstrap.includes('spl_autoload_register'), 'Bootstrap should register a fallback runtime autoloader.');
+ ensure(bootstrap.includes('runtime_class_file'), 'Bootstrap should resolve runtime classes through a fallback file mapper.');
+ ensure(attributeBag.includes('implements \\Stringable'), 'AttributeBag should serialize safely in Twig string contexts.');
+ ensure(attributeBag.includes('function addClass'), 'AttributeBag should support Core-style class merging.');
+ ensure(attributeBag.includes('function toString'), 'AttributeBag should expose explicit serialization.');
+ ensure(twig.includes("'needs_context' => true"), 'Timber helper functions should accept Twig context.');
+ ensure(twig.includes('new AttributeBag'), 'Twig helpers should return AttributeBag objects.');
+ ensure(smoke.includes('{{ bem("example-card", ["featured"]) }}'), 'Attribute helper smoke script should render a bem() Twig fixture.');
+ ensure(smoke.includes('{{ add_attributes({ class: ["foo"] }) }}'), 'Attribute helper smoke script should render an add_attributes() Twig fixture.');
+ return 'Attribute helper runtime and smoke fixture are wired.';
+ });
+
+ runStaticCheck('Twig switch tags', () => {
+ const twig = readFile('includes/Runtime/Twig.php');
+ const extension = readFile('includes/Twig/SwitchExtension.php');
+ const parser = readFile('includes/Twig/SwitchTokenParser.php');
+ const node = readFile('includes/Twig/SwitchNode.php');
+ const smoke = readFile('.github/scripts/twig-switch-smoke.php');
+
+ ensure(twig.includes("add_filter( 'timber/twig', array( $this, 'extensions' ) )"), 'Timber integration should register custom Twig extensions.');
+ ensure(twig.includes('new SwitchExtension()'), 'Timber integration should add the switch extension.');
+ ensure(extension.includes('new SwitchTokenParser()'), 'Switch extension should expose the switch token parser.');
+ ensure(parser.includes("return 'switch';") && parser.includes("case 'case':") && parser.includes("case 'default':") && parser.includes("case 'endswitch':"), 'Switch parser should support switch, case, default, and endswitch tags.');
+ ensure(node.includes("->write( 'switch (' )") && node.includes('->write( "break;\\n" )'), 'Switch node should compile native switch branches with automatic breaks.');
+ ensure(smoke.includes("{% case 'alpha' or 'beta' %}") && smoke.includes('automatic case break'), 'Twig switch smoke should cover multiple values and no fall-through.');
+ ensure(docs.twig.includes('## Switch statements') && docs.twig.includes('{% switch variant %}'), 'Twig authoring docs should document switch statements.');
+ return 'Twig switch tags are registered, documented, and covered by a runtime smoke test.';
+ });
+
+ runStaticCheck('Bootstrap runtime autoloading', () => {
+ const bootstrap = readFile('includes/Bootstrap.php');
+ const smoke = readFile('.github/scripts/bootstrap-loader-smoke.php');
+
+ ensure(bootstrap.includes("str_replace( '\\\\', '/', $relative_class )"), 'Bootstrap fallback loader should resolve PSR-4-shaped runtime paths.');
+ ensure(smoke.includes("require_once \\$repo_root . '/vendor/autoload.php'"), 'Bootstrap loader smoke should verify Composer autoloading.');
+ ensure(smoke.includes("require_once \\$repo_root . '/includes/Bootstrap.php'"), 'Bootstrap loader smoke should verify fallback loading without Composer.');
+ ensure(smoke.includes('Emulsify\\\\Theme\\\\Blocks\\\\Registry'), 'Bootstrap loader smoke should cover nested block runtime classes.');
+ ensure(smoke.includes('Fallback loader did not load'), 'Bootstrap loader smoke should fail clearly when fallback loading breaks.');
+ return 'Composer autoloading and Bootstrap fallback loading are covered.';
+ });
+
+ runStaticCheck('Child theme generator', () => {
+ const cli = readFile('includes/Cli/GenerateChildThemeCommand.php');
+ const smoke = readFile('.github/scripts/child-theme-generator-smoke.php');
+
+ ensure(cli.includes('[--machine-name=]'), 'WP-CLI help should document --machine-name.');
+ ensure(cli.includes('[--dry-run]'), 'WP-CLI help should document --dry-run.');
+ ensure(cli.includes('[--force]'), 'WP-CLI help should document --force.');
+ ensure(cli.includes('[--activate]'), 'WP-CLI help should document --activate.');
+ ensure(cli.includes('collect_metadata_updates'), 'Child theme generator should use targeted metadata updates.');
+ ensure(cli.includes("replace_theme_header( $contents, 'Theme Name'"), 'Child theme generator should update Theme Name explicitly.');
+ ensure(cli.includes("replace_theme_header( $contents, 'Text Domain'"), 'Child theme generator should update Text Domain explicitly.');
+ ensure(cli.includes("replace_theme_header( $contents, 'Template'"), 'Child theme generator should update Template explicitly.');
+ ensure(cli.includes("data['project']['name']"), 'Child theme generator should update project.emulsify.json project.name.');
+ ensure(cli.includes("data['project']['machineName']"), 'Child theme generator should update project.emulsify.json project.machineName.');
+ ensure(cli.includes("data['project']['generatedFrom']"), 'Child theme generator should update project.emulsify.json generatedFrom.');
+ ensure(cli.includes("data['project']['generatedFromVersion']"), 'Child theme generator should update project.emulsify.json generatedFromVersion.');
+ ensure(cli.includes("data['name'] = $machine_name"), 'Child theme generator should update package.json name.');
+ ensure(cli.includes('collect_pattern_updates'), 'Child theme generator should update starter pattern namespaces.');
+ ensure(cli.includes('get_destination_replacement_error'), 'Child theme generator should verify existing destinations before force replacement.');
+ ensure(cli.includes('project.platform: wordpress'), 'Child theme generator should require WordPress project metadata before force replacement.');
+ ensure(cli.includes('project.generatedFrom'), 'Child theme generator should use generated source metadata for force replacement safety.');
+ ensure(!cli.includes('rename_instances'), 'Child theme generator should not use blind recursive starter string replacement.');
+ ensure(smoke.includes("'machine-name' => 'acme-child'"), 'Child theme generator smoke should cover --machine-name.');
+ ensure(smoke.includes("'dry-run' => true"), 'Child theme generator smoke should cover --dry-run.');
+ ensure(smoke.includes("'force' => true"), 'Child theme generator smoke should cover --force.');
+ ensure(smoke.includes("'activate' => true"), 'Child theme generator smoke should cover --activate.');
+ ensure(smoke.includes('unrelated-theme'), 'Child theme generator smoke should prove --force refuses unrelated theme directories.');
+ ensure(smoke.includes('Would replace existing destination because --force was provided'), 'Child theme generator smoke should prove --dry-run --force reports replacement intent.');
+ ensure(smoke.includes('project.emulsify.json'), 'Child theme generator smoke should validate project.emulsify.json updates.');
+ ensure(smoke.includes("assets/images/.gitkeep"), 'Child theme generator smoke should validate copied image asset placeholders.');
+ ensure(smoke.includes("assets/icons/.gitkeep"), 'Child theme generator smoke should validate copied icon asset placeholders.');
+ ensure(smoke.includes("'wordpress' === $project['project']['platform']"), 'Child theme generator smoke should validate the WordPress platform adapter.');
+ ensure(smoke.includes("'emulsify-wordpress' === $project['project']['generatedFrom']"), 'Child theme generator smoke should validate generatedFrom metadata.');
+ ensure(smoke.includes("'2.0.0' === $project['project']['generatedFromVersion']"), 'Child theme generator smoke should validate generatedFromVersion metadata.');
+ ensure(smoke.includes('foreign-generator'), 'Child theme generator smoke should reject conflicting generatedFrom metadata.');
+ ensure(smoke.includes('smoke-pattern.json'), 'Child theme generator smoke should validate optional copied pattern namespace updates.');
+ ensure(smoke.includes("! is_dir( $destination . '/src/components/button' )"), 'Child theme generator smoke should prove removed starter components are not copied.');
+ ensure(smoke.includes("! is_dir( $destination . '/src/editor' )"), 'Child theme generator smoke should prove assumed editor modules are not copied.');
+ ensure(smoke.includes("! is_dir( $destination . '/src/foundation' )"), 'Child theme generator smoke should prove assumed foundation directories are not copied.');
+ ensure(smoke.includes("! is_dir( $destination . '/src/layout' )"), 'Child theme generator smoke should prove assumed layout directories are not copied.');
+ ensure(smoke.includes("! is_file( $destination . '/src/foundation.scss' )"), 'Child theme generator smoke should prove assumed Sass entrypoints are not copied.');
+ ensure(smoke.includes("! is_file( $destination . '/theme.json' )"), 'Child theme generator smoke should prove empty child theme.json is not copied by default.');
+ return 'WP-CLI child theme generation uses safe options and targeted metadata updates.';
+ });
+
+ runStaticCheck('Component locator memoization', () => {
+ const locator = readFile('includes/Blocks/ComponentLocator.php');
+ const fileDiscovery = readFile('includes/Support/FileDiscovery.php');
+ const registry = readFile('includes/Blocks/Registry.php');
+ const acfBlocks = readFile('includes/Blocks/AcfBlocks.php');
+ const nativeBlocks = readFile('includes/Blocks/NativeBlocks.php');
+ const smoke = readFile('.github/scripts/component-locator-smoke.php');
+
+ ensure(locator.includes('private $component_roots'), 'Component locator should memoize component roots per request.');
+ ensure(locator.includes('private $component_files'), 'Component locator should memoize the recursive component file index per request.');
+ ensure(locator.includes('private $acf_components'), 'Component locator should memoize ACF/Twig component records per request.');
+ ensure(locator.includes('private $native_block_directories'), 'Component locator should memoize native block directory records per request.');
+ ensure(locator.includes('private $skipped_duplicates'), 'Component locator should track skipped duplicate component records.');
+ ensure(locator.includes('function component_files'), 'Component locator should expose a shared internal component file index.');
+ ensure(locator.includes('$this->component_files()'), 'ACF/Twig and native discovery should use the shared component file index.');
+ ensure(locator.includes('FileDiscovery::theme_roots') && locator.includes('FileDiscovery::file_records'), 'Component locator should use shared filesystem discovery helpers.');
+ ensure(locator.includes('acf_component_slug'), 'Component locator should detect duplicate ACF/Twig component slugs.');
+ ensure(locator.includes('native_block_name'), 'Component locator should detect duplicate native block names.');
+ ensure(fileDiscovery.includes('get_stylesheet_directory()') && fileDiscovery.includes('get_template_directory()'), 'File discovery helper should build child and parent theme roots.');
+ ensure(fileDiscovery.indexOf('get_stylesheet_directory()') < fileDiscovery.indexOf('get_template_directory()'), 'File discovery helper should keep child roots before parent roots.');
+ ensure(locator.includes('emulsify_theme_component_discovery_cache_enabled') && locator.includes('get_transient') && locator.includes('set_transient'), 'Component locator should expose opt-in transient-backed persistent discovery caching.');
+ ensure(locator.includes('clear_discovery_cache') && locator.includes('delete_transient'), 'Component locator should provide a clear method for the active discovery cache key.');
+ ensure(locator.includes('stylesheet_version') && locator.includes('template_version') && locator.includes('manifest_file_signature'), 'Component locator cache key should include theme versions and manifest filemtime.');
+ ensure(locator.includes('wp_get_environment_type') && locator.includes('WP_DEBUG'), 'Component locator should keep active development uncached unless explicitly enabled.');
+ ensure(registry.includes('$components = new ComponentLocator()'), 'Block registry should share one ComponentLocator instance.');
+ ensure(acfBlocks.includes('$this->components->acf_components()'), 'ACF/Twig block discovery should use ComponentLocator.');
+ ensure(acfBlocks.includes('acf_block_name'), 'ACF/Twig block registration should skip duplicate final ACF block names.');
+ ensure(acfBlocks.includes('acf_get_block_type'), 'ACF/Twig block registration should avoid already registered ACF block names.');
+ ensure(nativeBlocks.includes('$this->components->native_block_directories()'), 'Native block discovery should use ComponentLocator.');
+ ensure(nativeBlocks.includes('native_registered_block_name'), 'Native block registration should avoid already registered native block names.');
+ ensure(smoke.includes('late-native'), 'Component locator smoke should prove native discovery reuses the memoized file index.');
+ ensure(smoke.includes('late-card'), 'Component locator smoke should prove repeated ACF/Twig discovery is memoized per locator instance.');
+ ensure(smoke.includes('Child ACF/Twig component metadata should override'), 'Component locator smoke should verify child ACF/Twig priority.');
+ ensure(smoke.includes('Child native block metadata should override'), 'Component locator smoke should verify child native block priority.');
+ ensure(smoke.includes('duplicate component slugs'), 'Component locator smoke should verify duplicate ACF/Twig component slug reporting.');
+ ensure(smoke.includes('duplicate block.json name values'), 'Component locator smoke should verify duplicate native block name reporting.');
+ ensure(smoke.includes('emulsify-shared-acf'), 'Component locator smoke should verify duplicate normalized final ACF block name handling.');
+ ensure(smoke.includes('Missing persistent discovery cache should fall back') && smoke.includes('Enabled persistent discovery cache should be reused'), 'Component locator smoke should cover enabled persistent cache behavior.');
+ ensure(smoke.includes('Changing the child theme version should change') && smoke.includes('Changing the asset manifest mtime should change'), 'Component locator smoke should cover cache key invalidation.');
+ ensure(smoke.includes('Invalid persistent discovery cache data should fall back'), 'Component locator smoke should cover invalid cache fallback.');
+ return 'Component locator memoizes request-local discovery and offers opt-in persistent caching with duplicate safety.';
+ });
+
+ runStaticCheck('Runtime filters', () => {
+ const acfJson = readFile('includes/Acf/LocalJson.php');
+ const assets = readFile('includes/Runtime/Assets.php');
+ const twig = readFile('includes/Runtime/Twig.php');
+ const context = readFile('includes/Runtime/Context.php');
+ const setup = readFile('includes/Runtime/Setup.php');
+ const editorEnhancements = readFile('includes/Editor/Enhancements.php');
+ const editorPolicy = readFile('includes/Editor/Policy.php');
+ const editorAllowedBlocks = readFile('includes/Editor/AllowedBlockTypes.php');
+ const editorBlockSupport = readFile('includes/Editor/BlockSupportOverrides.php');
+ const editorPatterns = readFile('includes/Editor/PatternGovernance.php');
+ const editorPolicyOptions = readFile('includes/Editor/PolicyOptions.php');
+ const editorUserPatterns = readFile('includes/Editor/UserPatternPermissions.php');
+ const patterns = readFile('includes/Blocks/Patterns.php');
+ const locator = readFile('includes/Blocks/ComponentLocator.php');
+ const assetManifest = readFile('includes/Support/AssetManifest.php');
+ const fileDiscovery = readFile('includes/Support/FileDiscovery.php');
+ const acfBlocks = readFile('includes/Blocks/AcfBlocks.php');
+ const nativeBlocks = readFile('includes/Blocks/NativeBlocks.php');
+ const acfJsonSmoke = readFile('.github/scripts/acf-local-json-smoke.php');
+ const assetManifestSmoke = readFile('.github/scripts/asset-manifest-smoke.php');
+ const blockAssetSmoke = readFile('.github/scripts/block-scoped-assets-smoke.php');
+ const componentLocatorSmoke = readFile('.github/scripts/component-locator-smoke.php');
+ const editorEnhancementsSmoke = readFile('.github/scripts/editor-enhancements-smoke.php');
+ const smoke = readFile('.github/scripts/theme-filters-smoke.php');
+ const editorPolicySmoke = readFile('.github/scripts/editor-policy-smoke.php');
+ const patternSmoke = readFile('.github/scripts/pattern-registry-smoke.php');
+ const smokeText = [acfJsonSmoke, assetManifestSmoke, blockAssetSmoke, componentLocatorSmoke, editorEnhancementsSmoke, smoke, editorPolicySmoke, patternSmoke].join('\n');
+ const expectedFilters = [
+ 'emulsify_theme_acf_json_enabled',
+ 'emulsify_theme_acf_json_save_path',
+ 'emulsify_theme_acf_json_load_paths',
+ 'emulsify_theme_acf_json_remove_default_load_path',
+ 'emulsify_theme_asset_directories',
+ 'emulsify_theme_asset_files',
+ 'emulsify_theme_asset_manifest_path',
+ 'emulsify_theme_asset_manifest_data',
+ 'emulsify_theme_editor_enhancements_config',
+ 'emulsify_theme_editor_asset_directories',
+ 'emulsify_theme_editor_asset_files',
+ 'emulsify_theme_twig_namespaces',
+ 'emulsify_theme_context',
+ 'emulsify_theme_acf_block_metadata',
+ 'emulsify_theme_acf_block_args',
+ 'emulsify_theme_acf_block_asset_records',
+ 'emulsify_theme_native_block_directories',
+ 'emulsify_theme_pattern_directories',
+ 'emulsify_theme_pattern_data',
+ 'emulsify_theme_pattern_categories',
+ 'emulsify_theme_pattern_args',
+ 'emulsify_theme_component_roots',
+ 'emulsify_theme_component_discovery_cache_enabled',
+ 'emulsify_theme_component_discovery_cache_key_parts',
+ 'emulsify_theme_component_discovery_cache_ttl',
+ 'emulsify_theme_setup_options',
+ 'emulsify_theme_editor_policy_options',
+ 'emulsify_theme_allowed_block_types',
+ 'emulsify_theme_pattern_namespaces',
+ 'emulsify_theme_block_support_overrides',
+ ];
+ const runtimeText = [
+ acfJson,
+ assets,
+ twig,
+ context,
+ setup,
+ editorEnhancements,
+ editorPolicy,
+ editorAllowedBlocks,
+ editorBlockSupport,
+ editorPatterns,
+ editorPolicyOptions,
+ editorUserPatterns,
+ patterns,
+ locator,
+ assetManifest,
+ acfBlocks,
+ nativeBlocks,
+ ].join('\n');
+
+ for (const filter of expectedFilters) {
+ ensure(runtimeText.includes(filter), `${filter} should be registered in runtime PHP code.`);
+ ensure(smokeText.includes(filter), `${filter} should be covered by a runtime filter smoke test.`);
+ ensure(docsText.includes(filter), `${filter} should be documented in docs.`);
+ }
+
+ ensure(acfJsonSmoke.includes('ACF Local JSON smoke checks passed'), 'ACF Local JSON smoke should have a clear success message.');
+ ensure(assetManifestSmoke.includes('Asset manifest smoke checks passed'), 'Asset manifest smoke should have a clear success message.');
+ ensure(blockAssetSmoke.includes('Block scoped asset smoke checks passed'), 'Block scoped asset smoke should have a clear success message.');
+ ensure(editorEnhancementsSmoke.includes('Editor enhancements smoke checks passed'), 'Editor enhancements smoke should have a clear success message.');
+ ensure(smoke.includes('Theme filter smoke checks passed'), 'Runtime filter smoke should have a clear success message.');
+ ensure(editorPolicySmoke.includes('Editor policy smoke checks passed'), 'Editor policy smoke should have a clear success message.');
+ ensure(patternSmoke.includes('Pattern registry smoke checks passed'), 'Pattern registry smoke should have a clear success message.');
+ ensure(fileDiscovery.includes('function theme_roots'), 'File discovery helper should create child-first theme roots.');
+ ensure(fileDiscovery.includes('function normalize_roots'), 'File discovery helper should normalize readable unique roots.');
+ ensure(fileDiscovery.includes('function file_records'), 'File discovery helper should create file records for service-specific filtering.');
+ ensure(fileDiscovery.includes('function recursive_files'), 'File discovery helper should support recursive scans.');
+ ensure(fileDiscovery.includes('function directory_files'), 'File discovery helper should support shallow directory scans.');
+ ensure(fileDiscovery.includes('function relative_path'), 'File discovery helper should normalize POSIX relative paths.');
+ ensure(fileDiscovery.includes('function sort_by_priority_and_relative'), 'File discovery helper should provide stable priority sorting.');
+ ensure(assetManifest.includes('dist/emulsify-assets.json'), 'Asset manifest helper should default to dist/emulsify-assets.json.');
+ ensure(assetManifest.includes('emulsify_theme_asset_manifest_path') && assetManifest.includes('emulsify_theme_asset_manifest_data'), 'Asset manifest helper should expose path and parsed data filters.');
+ ensure(assets.includes("'global'") && editorEnhancements.includes("'editor'") && assetManifest.includes("'components'") && assetManifest.includes("'blocks'"), 'Asset manifest support should cover global, editor, component, and block-specific sections.');
+ ensure(assets.includes('FileDiscovery::theme_roots') && assets.includes('FileDiscovery::file_records'), 'Runtime assets should use shared filesystem discovery helpers.');
+ ensure(assets.includes('AssetManifest') && assets.includes('manifest_asset_files') && assets.includes("enqueue_scripts( 'emulsify-global', 'dist/global' )"), 'Runtime assets should support manifest-backed global and component assets.');
+ ensure(assets.includes('is_scoped_component_asset') && assets.includes('*.component.json'), 'Runtime assets should skip component files declared as block-scoped metadata.');
+ ensure(editorEnhancements.includes('FileDiscovery::theme_roots') && editorEnhancements.includes('FileDiscovery::file_records'), 'Editor assets should use shared filesystem discovery helpers.');
+ ensure(editorEnhancements.includes('AssetManifest') && editorEnhancements.includes("asset_records( 'editor'"), 'Editor assets should support manifest-backed editor assets.');
+ ensure(acfBlocks.includes('scoped_asset_records') && acfBlocks.includes('enqueue_assets') && acfBlocks.includes('emulsify_theme_acf_block_asset_records'), 'ACF/Twig blocks should support metadata-driven scoped assets.');
+ ensure(nativeBlocks.includes("register_block_type( (string) $component['path'] )"), 'Native blocks should delegate block.json asset fields to WordPress register_block_type().');
+ ensure(locator.includes('FileDiscovery::theme_roots') && locator.includes('FileDiscovery::file_records'), 'Component locator should use shared filesystem discovery helpers.');
+ ensure(patterns.includes('FileDiscovery::theme_roots') && patterns.includes("FileDiscovery::file_records( $this->pattern_directories(), array( 'json' ), false )"), 'Pattern discovery should use shared helpers while staying shallow.');
+ ensure(patterns.includes('CATEGORY_METADATA_FILES') && patterns.includes('category_metadata'), 'Pattern discovery should support optional category metadata files.');
+ ensure(twig.includes('FileDiscovery::normalize_roots'), 'Twig project component roots should use shared root normalization.');
+ ensure(smoke.includes('Asset discovery should keep child roots before parent fallback roots'), 'Runtime filter smoke should verify asset root priority.');
+ ensure(editorEnhancementsSmoke.includes('skip duplicate parent relative paths'), 'Editor enhancements smoke should verify editor asset duplicate handling.');
+ ensure(patternSmoke.includes('Child pattern JSON files should override parent files with the same basename'), 'Pattern smoke should verify child-first pattern discovery.');
+ ensure(patternSmoke.includes('Child pattern category metadata should override parent category labels') && patternSmoke.includes('metadata files'), 'Pattern smoke should cover category metadata and metadata-file skips.');
+ ensure(assetManifestSmoke.includes('No manifest should fall back') && assetManifestSmoke.includes('Invalid manifest should fall back') && assetManifestSmoke.includes('Child manifest should take priority'), 'Asset manifest smoke should cover fallback, invalid, and child-priority paths.');
+ ensure(blockAssetSmoke.includes('Manifest block assets should take priority') && blockAssetSmoke.includes('Native block.json asset fields should be left for WordPress') && blockAssetSmoke.includes('Global component scanning should remain the fallback'), 'Block scoped asset smoke should cover manifest priority, native block.json delegation, and scanner fallback.');
+ return 'Parent runtime exposes documented filters with smoke coverage.';
+ });
+
+ runStaticCheck('Whisk Core 4 and Vite metadata', () => {
+ const initHook = readFile('whisk/.cli/init.js');
+ const jestConfig = readFile('whisk/config/jest.config.js');
+ const scripts = whiskPackage.scripts || {};
+ const scriptText = Object.values(scripts).join('\n');
+ const starterComponentFiles = listFilesRecursive('whisk/src/components', (file) => path.basename(file) !== '.gitkeep');
+ const starterPatternFiles = listFilesRecursive('whisk/patterns', (file) => path.basename(file) !== '.gitkeep');
+ ensure(whiskPackage.name === 'whisk', 'whisk/package.json name should remain whisk.');
+ ensure(semver(whiskPackage.version), 'whisk/package.json version must be a valid semver string.');
+ ensure(whiskPackage.version === '2.0.0', 'whisk/package.json version should prepare the 2.0.0 release.');
+ ensure(whiskPackage.description, 'whisk/package.json description is required.');
+ ensureGeneratedChildThemeLanguage('whisk/package.json description', whiskPackage.description);
+ ensure(whiskPackage.license === 'GPL-2.0-only', 'whisk/package.json license should align with the WordPress theme.');
+ ensure(whiskPackage.engines && whiskPackage.engines.node === '>=24', 'whisk/package.json engines.node should be >=24.');
+ ensure(whiskPackage.type === 'module', 'whisk/package.json should remain an ES module package.');
+ ensure(whiskProject.project.platform === 'wordpress', 'whisk/project.emulsify.json should use the WordPress platform adapter.');
+ ensure(whiskProject.project.name === 'whisk', 'whisk/project.emulsify.json project.name should remain whisk.');
+ ensure(whiskProject.project.machineName === 'whisk', 'whisk/project.emulsify.json project.machineName should remain whisk.');
+ ensure(whiskProject.project.generatedFrom === 'emulsify-wordpress', 'whisk/project.emulsify.json should identify the generated child theme source.');
+ ensure(whiskProject.project.generatedFromVersion === '2.0.0', 'whisk/project.emulsify.json should record the generated child theme source version.');
+ ensure(whiskProject.starter.repository === 'https://github.com/emulsify-ds/emulsify-wordpress-starter', 'whisk/project.emulsify.json should point to the standalone WordPress starter repository.');
+ ensure(fs.existsSync(path.join(repoRoot, 'whisk/.cli/init.js')), 'Whisk should ship an emulsify-cli init hook.');
+ ensure(fs.existsSync(path.join(repoRoot, 'whisk/.gitignore')), 'Whisk should ship standalone starter ignore rules.');
+ ensure(fs.existsSync(path.join(repoRoot, 'whisk/.nvmrc')), 'Whisk should use .nvmrc for Node version tooling.');
+ ensure(!fs.existsSync(path.join(repoRoot, 'whisk/.nvm')), 'Whisk should not use the legacy .nvm filename.');
+ ensure(fs.existsSync(path.join(repoRoot, 'whisk/config/jest.config.js')), 'Whisk should provide the Jest config referenced by package scripts.');
+ ensure(scripts.test === 'jest --coverage --passWithNoTests --config ./config/jest.config.js', 'whisk/package.json test script should point at the checked-in Jest config.');
+ ensure(jestConfig.includes("testEnvironment: 'node'"), 'whisk/config/jest.config.js should define a node test environment.');
+ ensure(initHook.includes("replaceThemeHeader(contents, 'Theme Name', name)"), 'Whisk init hook should update style.css Theme Name.');
+ ensure(initHook.includes("replaceThemeHeader(contents, 'Text Domain', machineName)"), 'Whisk init hook should update style.css Text Domain.');
+ ensure(initHook.includes("replaceThemeHeader(contents, 'Template', PARENT_THEME)"), 'Whisk init hook should keep Template aligned to the parent theme.');
+ ensure(initHook.includes("const PARENT_THEME = 'emulsify'"), 'Whisk init hook should keep the parent Template slug as emulsify.');
+ ensure(initHook.includes("data.name = machineName"), 'Whisk init hook should update package metadata names.');
+ ensure(initHook.includes("updateLockfile('package-lock.json'"), 'Whisk init hook should update the package lockfile created before the hook runs.');
+ ensure(initHook.includes("config.project.platform = 'wordpress'"), 'Whisk init hook should keep project.platform on wordpress.');
+ ensure(initHook.includes("config.project.generatedFrom = GENERATED_FROM"), 'Whisk init hook should set generatedFrom metadata.');
+ ensure(initHook.includes("config.project.generatedFromVersion = generatedFromVersion"), 'Whisk init hook should set generatedFromVersion metadata.');
+ ensure(initHook.includes('updatePatternNamespaces'), 'Whisk init hook should update JSON pattern namespaces.');
+ ensure(starterInitSmoke.includes('package-lock.json') && starterInitSmoke.includes('packages[""].name'), 'Starter init smoke should prove lockfile metadata is updated after npm install.');
+ ensure(starterInitSmoke.includes("project.project.platform === 'wordpress'"), 'Starter init smoke should validate the WordPress platform adapter.');
+ ensure(starterInitSmoke.includes("project.project.generatedFrom === 'emulsify-wordpress'"), 'Starter init smoke should validate generatedFrom metadata.');
+ ensure(starterInitSmoke.includes("project.project.generatedFromVersion === '2.0.0'"), 'Starter init smoke should validate generatedFromVersion metadata.');
+ ensure(starterInitSmoke.includes("style.Template === 'emulsify'"), 'Starter init smoke should validate Template: emulsify.');
+ ensure(starterInitSmoke.includes('node_modules') && starterInitSmoke.includes('dist'), 'Starter init smoke should validate copied build and dependency output is absent.');
+ ensure(starterInitSmoke.includes("['--prefix', 'whisk', 'run', 'test']"), 'Starter init smoke should verify the starter npm test script works after Whisk dependencies are installed.');
+ ensure(fs.existsSync(path.join(repoRoot, 'whisk/src/components/.gitkeep')), 'whisk/src/components should remain as an empty optional component placeholder.');
+ ensure(!fs.existsSync(path.join(repoRoot, 'whisk/theme.json')), 'Whisk should not ship an empty child theme.json by default.');
+ ensure(!fs.existsSync(path.join(repoRoot, 'whisk/components')), 'whisk/components should not remain as an unused starter placeholder.');
+ ensure(starterComponentFiles.length === 0, `Whisk should not ship concrete starter component files: ${starterComponentFiles.join(', ')}.`);
+ ensure(fs.existsSync(path.join(repoRoot, 'whisk/patterns/.gitkeep')), 'whisk/patterns should remain as an empty optional pattern placeholder.');
+ ensure(starterPatternFiles.length === 0, `Whisk should not ship concrete starter pattern files: ${starterPatternFiles.join(', ')}.`);
+ for (const entryFile of ['foundation.scss', 'layout.scss', 'tokens.scss']) {
+ ensure(!fs.existsSync(path.join(repoRoot, 'whisk/src', entryFile)), `whisk/src/${entryFile} should not assume a selected component library.`);
+ }
+ for (const directory of ['editor', 'foundation', 'layout']) {
+ ensure(!fs.existsSync(path.join(repoRoot, 'whisk/src', directory)), `whisk/src/${directory} should not assume a selected component library.`);
+ }
+ ensure(!fs.existsSync(path.join(repoRoot, 'whisk/whisk.info.yml')), 'Whisk should not add Drupal-style .info.yml metadata.');
+ ensure(!fs.existsSync(path.join(repoRoot, 'whisk/scripts/vite-if-inputs.mjs')), 'Whisk should not wrap Emulsify Core Vite build errors.');
+ ensure(whiskPackage.dependencies && whiskPackage.dependencies['@emulsify/core'], 'whisk/package.json must declare @emulsify/core.');
+ ensure(whiskPackage.dependencies['@emulsify/core'] === '^4.1.0', 'whisk/package.json should target Emulsify Core ^4.1.0.');
+ ensure(scripts.build && scripts.build.includes('vite build --config node_modules/@emulsify/core/config/vite/vite.config.js'), 'whisk/package.json build script should use the Emulsify Core Vite config directly.');
+ ensure(scripts.vite && scripts.vite.includes('vite build --watch --config node_modules/@emulsify/core/config/vite/vite.config.js'), 'whisk/package.json should expose the Emulsify Core Vite watch script directly.');
+ ensure(!scriptText.includes('vite-if-inputs'), 'whisk/package.json scripts should not wrap missing-input build errors.');
+ ensure(scripts.develop && scripts.develop.includes('npm:vite'), 'whisk/package.json develop script should run the Vite watcher.');
+ ensure(!scripts.webpack, 'whisk/package.json should not expose a webpack script.');
+ ensure(!scripts['build-dev'], 'whisk/package.json should not expose the old Webpack build-dev script.');
+ ensure(!scripts['tokens:transform'], 'whisk/package.json should not ship a default token-transformer script.');
+ ensure(!scripts['tokens:build'], 'whisk/package.json should not ship a default token build script.');
+ ensure(!scripts['style-dictionary:build'], 'whisk/package.json should not ship a default Style Dictionary script.');
+ ensure(!/\bwebpack\b/i.test(scriptText), 'whisk/package.json scripts should not reference Webpack.');
+ ensure(!/\btoken-transformer\b/.test(scriptText), 'whisk/package.json scripts should not reference token-transformer.');
+ ensure(!/\bstyle-dictionary\b/.test(scriptText), 'whisk/package.json scripts should not reference Style Dictionary.');
+ ensure(!String(whiskPackage.dependencies['@emulsify/core']).startsWith('^3.'), 'whisk/package.json should not target Emulsify Core 3.');
+ return `Whisk targets ${whiskPackage.dependencies['@emulsify/core']} with Vite scripts.`;
+ });
+
+ runStaticCheck('Template fallback model', () => {
+ const twigIntegration = readFile('includes/Runtime/Twig.php');
+ const projectComponentLoader = readFile('includes/Twig/ProjectComponentLoader.php');
+ const twigNamespaceSmoke = readFile('.github/scripts/twig-project-namespace-smoke.php');
+ const childFunctions = readFile('whisk/functions.php');
+ const childPageTemplate = readFile('whisk/templates/page.twig');
+ const parentTemplateFiles = listFilesRecursive('templates', (file) => file.endsWith('.twig')).sort();
+ const childTemplateFiles = listFilesRecursive('whisk/templates', (file) => file.endsWith('.twig')).sort();
+ const expectedParentFallbacks = [
+ 'templates/404.twig',
+ 'templates/archive.twig',
+ 'templates/author.twig',
+ 'templates/index.twig',
+ 'templates/page.twig',
+ 'templates/search.twig',
+ 'templates/single-password.twig',
+ 'templates/single.twig',
+ ];
+ const unexpectedChildTemplates = childTemplateFiles.filter((file) => file !== 'whisk/templates/page.twig');
+ const duplicateChildTemplates = childTemplateFiles.filter((file) => {
+ const parentFile = file.replace(/^whisk\//, '');
+ const parentPath = path.join(repoRoot, parentFile);
+ return fs.existsSync(parentPath) && readFile(file) === readFile(parentFile);
+ });
+ const childTemplatePathIndex = twigIntegration.indexOf("'path' => get_stylesheet_directory() . '/templates'");
+ const parentTemplatePathIndex = twigIntegration.indexOf("'path' => get_template_directory() . '/templates'");
+ const childComponentSrcPathIndex = twigIntegration.indexOf("'path' => get_stylesheet_directory() . '/src/components'");
+ const childComponentLegacyPathIndex = twigIntegration.indexOf("'path' => get_stylesheet_directory() . '/components'");
+ const parentComponentSrcPathIndex = twigIntegration.indexOf("'path' => get_template_directory() . '/src/components'");
+ const parentComponentLegacyPathIndex = twigIntegration.indexOf("'path' => get_template_directory() . '/components'");
+
+ for (const fallback of expectedParentFallbacks) {
+ ensure(parentTemplateFiles.includes(fallback), `${fallback} should exist as a parent fallback.`);
+ }
+ ensure(unexpectedChildTemplates.length === 0, `Whisk should not duplicate parent fallback templates: ${unexpectedChildTemplates.join(', ')}.`);
+ ensure(duplicateChildTemplates.length === 0, `Whisk templates should not be byte-identical parent copies: ${duplicateChildTemplates.join(', ')}.`);
+ ensure(childPageTemplate.includes("{% extends '@emulsify-tpl/page.twig' %}"), 'whisk/templates/page.twig should extend the parent-only page fallback.');
+ ensure(childPageTemplate.includes('{{ parent() }}'), 'whisk/templates/page.twig should demonstrate wrapping parent fallback output.');
+ ensure(childTemplatePathIndex !== -1, 'Twig integration should register child @templates path.');
+ ensure(parentTemplatePathIndex !== -1, 'Twig integration should register parent @templates path.');
+ ensure(childTemplatePathIndex < parentTemplatePathIndex, 'Twig integration should register child @templates before parent @templates.');
+ ensure(twigIntegration.includes("'namespace' => 'emulsify-tpl'") && twigIntegration.includes("'path' => get_template_directory() . '/templates'"), 'Twig integration should expose parent templates through @emulsify-tpl.');
+ ensure(childComponentSrcPathIndex !== -1, 'Twig integration should register child src @components path.');
+ ensure(childComponentLegacyPathIndex !== -1, 'Twig integration should register child legacy @components path.');
+ ensure(parentComponentSrcPathIndex !== -1, 'Twig integration should register parent src @components path.');
+ ensure(parentComponentLegacyPathIndex !== -1, 'Twig integration should register parent legacy @components path.');
+ ensure(childComponentSrcPathIndex < childComponentLegacyPathIndex, 'Twig integration should check child src/components compatibility roots before child components roots.');
+ ensure(childComponentLegacyPathIndex < parentComponentSrcPathIndex, 'Twig integration should register child @components paths before parent @components paths.');
+ ensure(parentComponentSrcPathIndex < parentComponentLegacyPathIndex, 'Twig integration should check parent src/components compatibility roots before parent components roots.');
+ ensure(twigIntegration.includes('project.emulsify.json'), 'Twig integration should read active child project.emulsify.json metadata.');
+ ensure(twigIntegration.includes('project_structure_namespaces') && twigIntegration.includes('structureImplementations'), 'Twig integration should honor Emulsify Core structureImplementations for configured namespaces.');
+ ensure(twigIntegration.includes('emulsify_theme_project_component_roots'), 'Twig integration should expose a focused project component roots filter.');
+ ensure(twigIntegration.includes('new ProjectComponentLoader') && projectComponentLoader.includes('implements \\Twig\\Loader\\LoaderInterface'), 'Twig integration should wrap the loader for machineName:component references.');
+ ensure(twigIntegration.includes('machineName:component'), 'Twig integration should document the project component reference intent in code comments.');
+ ensure(twigNamespaceSmoke.includes('@custom/teaser.twig') && twigNamespaceSmoke.includes('variant.structureImplementations'), 'Twig namespace smoke should verify configured Core structure namespaces.');
+ ensure(!fs.existsSync(path.join(repoRoot, 'whisk/includes/twig-namespaces.php')), 'Whisk should rely on the parent Twig namespace integration by default.');
+ ensure(!childFunctions.includes('twig-namespaces.php'), 'whisk/functions.php should not require a duplicate Twig namespace file.');
+ ensure(childFunctions.includes('project_machine_name:component_name') && childFunctions.includes('@components for compatible component libraries'), 'whisk/functions.php should document generic project machine-name component references while preserving @components compatibility.');
+ for (const route of ['home', 'page', 'single', 'archive', 'search', 'author', '404']) {
+ ensure(wordpressFixtureSmoke.includes(`name: '${route}'`), `WordPress fixture smoke should render the ${route} route.`);
+ }
+ ensure(wordpressFixtureSmoke.includes("const required = process.env.WP_SMOKE_REQUIRED === '1'"), 'WordPress fixture smoke should only require local prerequisites when WP_SMOKE_REQUIRED=1.');
+ ensure(!wordpressFixtureSmoke.includes("process.env.CI === 'true'"), 'WordPress fixture smoke should not treat all CI runs as required fixture runs.');
+ ensure(wordpressFixtureSmoke.includes("['theme', 'is-installed', 'emulsify']"), 'WordPress fixture smoke should prove the parent theme is installed.');
+ ensure(wordpressFixtureSmoke.includes("['emulsify', 'Smoke Generated'"), 'WordPress fixture smoke should generate a child theme from Whisk with WP-CLI.');
+ ensure(wordpressFixtureSmoke.includes("['option', 'get', 'stylesheet']"), 'WordPress fixture smoke should verify the generated child theme is active.');
+ ensure(wordpressFixtureSmoke.includes('assertGeneratedChildTheme'), 'WordPress fixture smoke should validate generated child theme metadata and copied Whisk files.');
+ ensure(wordpressFixtureSmoke.includes("project.project?.generatedFrom !== 'emulsify-wordpress'"), 'WordPress fixture smoke should validate generatedFrom metadata.');
+ ensure(wordpressFixtureSmoke.includes("project.project?.generatedFromVersion !== '2.0.0'"), 'WordPress fixture smoke should validate generatedFromVersion metadata.');
+ ensure(wordpressFixtureSmoke.includes('${themeSlug}-page'), 'WordPress fixture smoke should prove the generated child page template renders through Timber.');
+ ensure(wordpressFixtureSmoke.includes('checkGeneratedAssets'), 'WordPress fixture smoke should fetch generated child theme built assets.');
+ ensure(wordpressFixtureSmoke.includes('runAcfDiscoveryWithoutAcf'), 'WordPress fixture smoke should check ACF/Twig discovery when ACF is absent.');
+ ensure(wordpressFixtureSmoke.includes('installAcfStub'), 'WordPress fixture smoke should provide a fixture-only ACF stub.');
+ ensure(wordpressFixtureSmoke.includes('runAcfDiscoveryWithStub'), 'WordPress fixture smoke should check ACF/Twig registration with the ACF stub.');
+ ensure(wordpressFixtureSmoke.includes('emulsify/smoke-native'), 'WordPress fixture smoke should check native block.json discovery and registration.');
+ return 'Parent owns route fallbacks and default Twig namespaces; Whisk ships only the page override example.';
+ });
+
+ runStaticCheck('Starter asset placeholders', () => {
+ const iconFiles = listFilesRecursive('whisk/assets/icons', (file) => path.basename(file) !== '.gitkeep');
+ const expectedPlaceholders = [
+ 'whisk/assets/fonts/.gitkeep',
+ 'whisk/assets/icons/.gitkeep',
+ 'whisk/assets/images/.gitkeep',
+ ];
+
+ ensure(iconFiles.length === 0, `Remove client-specific starter icons: ${iconFiles.join(', ')}.`);
+ for (const placeholder of expectedPlaceholders) {
+ ensure(fs.existsSync(path.join(repoRoot, placeholder)), `${placeholder} should keep the starter asset directory.`);
+ }
+ ensure(wordpressFixtureSmoke.includes('assets/icons/.gitkeep') && wordpressFixtureSmoke.includes('assets/images/.gitkeep'), 'WordPress fixture smoke should validate copied asset placeholders.');
+ ensure(!fs.existsSync(path.join(repoRoot, 'whisk/assets/audio')), 'whisk/assets/audio should not ship as a default starter directory.');
+ ensure(!fs.existsSync(path.join(repoRoot, 'whisk/assets/video')), 'whisk/assets/video should not ship as a default starter directory.');
+ return 'Whisk keeps only generic starter asset placeholders.';
+ });
+
+ runStaticCheck('Duplicate package scripts', () => {
+ const duplicates = [
+ ...findDuplicatePackageScripts('package.json').map((key) => `package.json:${key}`),
+ ...findDuplicatePackageScripts('whisk/package.json').map((key) => `whisk/package.json:${key}`),
+ ];
+ ensure(duplicates.length === 0, `Duplicate script keys found: ${duplicates.join(', ')}.`);
+ return 'No duplicate script keys were found in package metadata.';
+ });
+
+ runStaticCheck('Semantic release configuration', () => {
+ const analyzerOptions = getReleasePluginOptions(releaseConfig, '@semantic-release/commit-analyzer');
+ const notesOptions = getReleasePluginOptions(releaseConfig, '@semantic-release/release-notes-generator');
+ const releaseAnalyzer = releaseConfig.plugins.find((plugin) => plugin && typeof plugin.analyzeCommits === 'function');
+ const releaseGuard = releaseConfig.plugins.find((plugin) => plugin && typeof plugin.verifyRelease === 'function');
+ ensure(releaseConfig.expectedStableRelease === '2.0.0', 'release.config.js should declare 2.0.0 as the expected stable release.');
+ ensure(releaseConfig.tagFormat === '${version}', 'release.config.js should emit non-prefixed semver tags.');
+ ensure(releaseConfig.repositoryUrl === 'git@github.com:emulsify-ds/emulsify-wordpress.git', 'release.config.js should publish against emulsify-wordpress.');
+ ensure(Array.isArray(releaseConfig.branches), 'release.config.js branches must be an array.');
+ ensure(releaseConfig.branches.length === 1 && releaseConfig.branches[0] === 'main', 'release.config.js should publish only from main.');
+ ensure(releaseAnalyzer, 'release.config.js should force the first stable release to a major release type.');
+ ensure(releaseGuard, 'release.config.js should guard the first stable release version.');
+ ensureBreakingParser('@semantic-release/commit-analyzer', analyzerOptions.parserOpts);
+ ensureBreakingParser('@semantic-release/release-notes-generator', notesOptions.parserOpts);
+ ensure(semanticReleaseWorkflow.includes('release-readiness:'), 'semantic-release.yml should run release-readiness before publishing.');
+ ensure(semanticReleaseWorkflow.includes('needs: release-readiness'), 'semantic-release.yml release job should wait for release readiness.');
+ ensure(semanticReleaseWorkflow.includes('contents: write'), 'semantic-release.yml should grant GitHub release permissions explicitly.');
+ ensure(semanticReleaseWorkflow.includes('id: semantic'), 'semantic-release.yml should expose the semantic-release step as steps.semantic.');
+ ensure(semanticReleaseWorkflow.includes('npm run release:check'), 'semantic-release.yml should run local release readiness checks.');
+ ensure(semanticReleaseWorkflow.includes('wp-cli'), 'semantic-release.yml should install WP-CLI for the WordPress smoke fixture.');
+ ensure(semanticReleaseWorkflow.includes('mysql:'), 'semantic-release.yml should provide a MySQL service for the WordPress smoke fixture.');
+ ensure(semanticReleaseWorkflow.includes('WP_SMOKE_DB_HOST'), 'semantic-release.yml should pass WordPress smoke database settings.');
+ try {
+ releaseGuard.verifyRelease({}, releaseGuardRejectContext);
+ throw new Error('release.config.js release guard should reject pre-2.0.0 releases.');
+ }
+ catch (error) {
+ const expectedMessage = error.message.includes('Expected semantic-release to prepare 2.0.0');
+ ensure(expectedMessage, 'release.config.js release guard should explain the expected 2.0.0 release.');
+ }
+ releaseGuard.verifyRelease({}, releaseGuardAcceptContext);
+ releaseGuard.verifyRelease({}, releaseGuardFutureContext);
+ ensure(releaseAnalyzer.analyzeCommits({}, releaseAnalyzerAlphaContext) === 'major', 'release.config.js should force a major release from the latest alpha tag.');
+ ensure(releaseAnalyzerAlphaContext.lastRelease.version === '1.0.0', 'release.config.js should normalize the latest alpha tag before semantic-release computes 2.0.0.');
+ ensure(releaseAnalyzer.analyzeCommits({}, releaseGuardRejectContext) === 'major', 'release.config.js should force a major release before 2.0.0 exists.');
+ ensure(releaseAnalyzer.analyzeCommits({}, releaseGuardFutureContext) === null, 'release.config.js should use normal commit analysis after 2.0.0 exists.');
+ return 'Semantic release is configured for non-prefixed tags, main-only publishing, and a forced 2.0.0 stable release.';
+ });
+
+ runStaticCheck('Theme readiness workflow', () => {
+ const prValidationScript = readFile('.github/scripts/pr-validation.cjs');
+
+ ensure(themeReadinessWorkflow.includes('name: WordPress Theme Readiness'), 'theme-readiness.yml should identify the WordPress theme readiness workflow.');
+ ensure(themeReadinessWorkflow.includes('pull_request:'), 'theme-readiness.yml should run for pull_request events.');
+ ensure(themeReadinessWorkflow.includes('workflow_dispatch:'), 'theme-readiness.yml should support manual dispatch.');
+ ensure(themeReadinessWorkflow.includes('schedule:'), 'theme-readiness.yml should run on a schedule.');
+ ensure(themeReadinessWorkflow.includes('release-2.x'), 'theme-readiness.yml should cover the release-2.x branch.');
+ ensure(themeReadinessWorkflow.includes('node-version-file: .nvmrc'), 'theme-readiness.yml should set up Node from .nvmrc.');
+ ensure(themeReadinessWorkflow.includes("php-version: '8.3'"), 'theme-readiness.yml should set up PHP 8.3.');
+ ensure(themeReadinessWorkflow.includes('npm ci --ignore-scripts'), 'theme-readiness.yml should install root npm dependencies cleanly.');
+ ensure(themeReadinessWorkflow.includes('composer validate --no-check-publish --strict'), 'theme-readiness.yml should validate Composer metadata.');
+ ensure(themeReadinessWorkflow.includes('npm audit --omit=dev'), 'theme-readiness.yml should run runtime npm audit.');
+ ensure(themeReadinessWorkflow.includes('npm audit'), 'theme-readiness.yml should run full npm audit.');
+ ensure(themeReadinessWorkflow.includes('npm run lint:php'), 'theme-readiness.yml should run PHP lint.');
+ ensure(themeReadinessWorkflow.includes('npm run pr:check'), 'theme-readiness.yml should delegate project smoke checks to npm run pr:check.');
+ ensure(themeReadinessWorkflow.includes('npm run release:check'), 'theme-readiness.yml should run release readiness checks.');
+ ensure(themeReadinessWorkflow.includes("github.event_name != 'pull_request'"), 'theme-readiness.yml should keep the WordPress fixture off normal pull requests.');
+ ensure(themeReadinessWorkflow.includes('wordpress_fixture enabled before merging'), 'theme-readiness.yml should document the 2.0 manual fixture gate.');
+ ensure(themeReadinessWorkflow.includes('mysql:'), 'theme-readiness.yml should provide MySQL for the WordPress fixture job.');
+ ensure(themeReadinessWorkflow.includes('wp-cli'), 'theme-readiness.yml should install WP-CLI for the WordPress fixture job.');
+ ensure(themeReadinessWorkflow.includes('WP_SMOKE_REQUIRED'), 'theme-readiness.yml should require the fixture smoke when the fixture job runs.');
+ ensure(themeReadinessWorkflow.includes('extended_checks'), 'theme-readiness.yml should expose optional extended checks.');
+ ensure(themeReadinessWorkflow.includes('npm --prefix whisk run a11y'), 'theme-readiness.yml should offer manual Storybook and accessibility checks.');
+ ensure(prValidationScript.includes('composer') && prValidationScript.includes('validate'), 'PR validation should validate Composer metadata.');
+ ensure(prValidationScript.includes('composer') && prValidationScript.includes('install'), 'PR validation should install Composer dependencies for Twig smoke coverage.');
+ ensure(prValidationScript.includes('lint:php'), 'PR validation should run PHP lint.');
+ ensure(prValidationScript.includes('smoke:acf-json'), 'PR validation should run the ACF Local JSON smoke test.');
+ ensure(prValidationScript.includes('smoke:asset-manifest'), 'PR validation should run the asset manifest smoke test.');
+ ensure(prValidationScript.includes('smoke:attributes'), 'PR validation should run the attribute helper smoke test.');
+ ensure(prValidationScript.includes('smoke:block-assets'), 'PR validation should run the block scoped asset smoke test.');
+ ensure(prValidationScript.includes('smoke:bootstrap-loader'), 'PR validation should run the Bootstrap loader smoke test.');
+ ensure(prValidationScript.indexOf('composer') < prValidationScript.indexOf('smoke:bootstrap-loader'), 'PR validation should install Composer dependencies before checking runtime autoloading.');
+ ensure(prValidationScript.includes('smoke:child-theme-generator'), 'PR validation should run the child theme generator smoke test.');
+ ensure(prValidationScript.includes('smoke:component-locator'), 'PR validation should run the component locator smoke test.');
+ ensure(prValidationScript.includes('smoke:editor-enhancements'), 'PR validation should run the editor enhancements smoke test.');
+ ensure(prValidationScript.includes('smoke:editor-policy'), 'PR validation should run the editor policy smoke test.');
+ ensure(prValidationScript.includes('smoke:patterns'), 'PR validation should run the pattern registry smoke test.');
+ ensure(prValidationScript.includes('smoke:starter-init'), 'PR validation should run the WordPress starter init smoke test.');
+ ensure(prValidationScript.includes('smoke:theme-filters'), 'PR validation should run the parent theme filter smoke test.');
+ ensure(prValidationScript.includes('smoke:twig-switch'), 'PR validation should run the Twig switch smoke test.');
+ ensure(prValidationScript.includes('smoke:twig-project-namespace'), 'PR validation should run the Twig project namespace smoke test.');
+ ensure(prValidationScript.includes('whisk:install'), 'PR validation should install Whisk dependencies.');
+ ensure(prValidationScript.indexOf('whisk:install') < prValidationScript.indexOf('smoke:starter-init'), 'PR validation should install Whisk dependencies before checking the starter npm test script.');
+ ensure(!prValidationScript.includes('whisk:build'), 'PR validation should not require a Whisk build before a component system is installed.');
+ return 'Theme readiness covers pragmatic PR checks with manual and scheduled WordPress fixture coverage.';
+ });
+
+ runStaticCheck('Release documentation', () => {
+ const expectedDocLinks = [
+ 'docs/README.md',
+ 'docs/upgrading-1x-to-2x.md',
+ 'docs/sister-project-parity.md',
+ 'docs/parent-child-architecture.md',
+ 'docs/timber-and-twig-authoring.md',
+ 'docs/core-4-vite-workflow.md',
+ 'docs/acf-local-json.md',
+ 'docs/acf-twig-blocks.md',
+ 'docs/native-gutenberg-blocks.md',
+ 'docs/block-patterns.md',
+ 'docs/editor-enhancements.md',
+ 'docs/editor-policy.md',
+ 'docs/asset-loading.md',
+ 'docs/wp-cli-child-theme-generation.md',
+ 'docs/component-recipes.md',
+ 'docs/release-process.md',
+ 'docs/post-2x-optimization-roadmap.md',
+ ];
+
+ ensure(readme.includes('Emulsify WordPress 2.0.0 is a Timber-first WordPress parent theme'), 'README.md should describe the 2.0.0 Timber-first parent theme.');
+ ensure(readme.includes('Emulsify WordPress is licensed under GPL-2.0-only'), 'README.md should document the GPL-2.0-only license.');
+ ensure(readme.includes('[LICENSE](LICENSE)'), 'README.md should link to the repository license file.');
+ ensure(readme.includes('## Requirements'), 'README.md should keep requirements visible.');
+ ensure(readme.includes('## Using Emulsify WordPress in a site project'), 'README.md should keep site project usage guidance visible.');
+ ensure(readme.includes('## Working inside a generated child theme'), 'README.md should keep child theme workflow guidance visible.');
+ ensure(readme.includes('## Parent and child themes'), 'README.md should keep the parent/child overview visible.');
+ ensure(readme.includes('## Developing or releasing the parent theme'), 'README.md should keep parent maintainer commands visible.');
+ ensure(readme.includes('## Documentation'), 'README.md should link to deeper docs.');
+ ensure(readme.includes('Do not run root npm commands in the parent theme for normal site implementation'), 'README.md should distinguish parent npm tooling from project runtime work.');
+ ensure(readme.includes('Require `timber/timber` from the application-level Composer project'), 'README.md should document application-level Timber installation.');
+ ensure(readme.includes('npm ci --ignore-scripts'), 'README.md should document parent maintainer npm install.');
+ ensure(readme.includes('composer install'), 'README.md should document Composer install usage.');
+ ensure(readme.includes('whisk/project.emulsify.json') && readme.includes('"platform": "wordpress"'), 'README.md should explain the current project.emulsify.json platform setting.');
+ ensure(readme.includes('generatedFrom') && readme.includes('generatedFromVersion'), 'README.md should explain generated child theme source metadata.');
+ ensure(readme.includes('whisk/assets/images') && readme.includes('whisk/assets/icons'), 'README.md should document the generated child asset placeholders.');
+ ensure(readme.includes('wp emulsify "Acme Site" --machine-name=acme-site'), 'README.md should document child theme generator examples.');
+ ensure(readme.includes('docs/component-recipes.md'), 'README.md should link to component recipes.');
+ ensure(readme.includes('Normal PR checks do not start MySQL or run the full WordPress fixture'), 'README.md should distinguish practical PR checks from the full fixture.');
+ ensure(readme.includes('GitHub Actions > `WordPress Theme Readiness`'), 'README.md should tell maintainers where to run the manual fixture workflow.');
+ ensure(readme.includes('WP_SMOKE_REQUIRED=1'), 'README.md should document required WordPress fixture smoke behavior.');
+
+ for (const docLink of expectedDocLinks) {
+ ensure(readme.includes(docLink), `README.md should link to ${docLink}.`);
+ }
+
+ ensure(docs.upgrading.includes('Emulsify WordPress 2.x changes the project model'), 'Upgrade doc should explain the 2.x project model.');
+ ensure(docs.upgrading.includes('generatedFrom: "emulsify-wordpress"') && docs.upgrading.includes('Older generated child themes may not include these fields'), 'Upgrade doc should document generated child theme lineage metadata.');
+ ensure(docs.parity.includes('Emulsify WordPress is the WordPress sister project to Emulsify Drupal'), 'Sister-project parity doc should name the Drupal sister project.');
+ ensure(docs.parity.includes('The parent theme owns reusable CMS runtime behavior'), 'Sister-project parity doc should define parent runtime ownership.');
+ ensure(docs.parity.includes('The generated child theme owns project implementation'), 'Sister-project parity doc should define child theme ownership.');
+ ensure(docs.parity.includes('Whisk is the starter'), 'Sister-project parity doc should define Whisk as the starter.');
+ ensure(docs.parity.includes('Emulsify Core 4 provides the component workflow'), 'Sister-project parity doc should document the Core 4 workflow.');
+ ensure(docs.parity.includes('Vite builds frontend assets'), 'Sister-project parity doc should document Vite.');
+ ensure(docs.parity.includes('Storybook presents component examples'), 'Sister-project parity doc should document Storybook.');
+ ensure(docs.parity.includes('Twig is the component template language'), 'Sister-project parity doc should document Twig.');
+ ensure(docs.parity.includes('Node 24 is the expected JavaScript runtime'), 'Sister-project parity doc should document Node 24.');
+ ensure(docs.parity.includes('Built global assets are emitted under `dist/global`'), 'Sister-project parity doc should document global build output.');
+ ensure(docs.parity.includes('Built component assets and block metadata are emitted under `dist/components`'), 'Sister-project parity doc should document component build output.');
+ ensure(docs.parity.includes('Frontend rendering uses Timber'), 'Sister-project parity doc should document Timber as a WordPress difference.');
+ ensure(docs.twig.includes('## Switch statements') && docs.twig.includes("{% case 'primary' or 'secondary' %}"), 'Twig authoring docs should document switch tags and multiple case values.');
+ ensure(docs.release.includes('Twig switch tag smoke test'), 'Release documentation should list Twig switch smoke coverage.');
+ ensure(docs.parity.includes('WordPress theme identity lives in `style.css` headers'), 'Sister-project parity doc should document WordPress theme headers.');
+ ensure(docs.parity.includes('`theme.json` is the WordPress site and editor configuration surface'), 'Sister-project parity doc should document theme.json.');
+ ensure(docs.parity.includes('Whisk does not include a child `theme.json` by default'), 'Sister-project parity doc should explain why Whisk does not ship an empty child theme.json.');
+ ensure(docs.parity.includes('ACF/Twig block registration is an optional WordPress integration'), 'Sister-project parity doc should document ACF/Twig blocks.');
+ ensure(docs.parity.includes('Native Gutenberg blocks use WordPress `block.json` metadata'), 'Sister-project parity doc should document native block.json blocks.');
+ ensure(docs.parity.includes("WordPress project generation is handled by the parent theme's WP-CLI command"), 'Sister-project parity doc should document WP-CLI generation.');
+ ensure(docs.parity.includes('`project.emulsify.json` uses `"platform": "wordpress"`'), 'Sister-project parity doc should document the WordPress platform adapter.');
+ ensure(docs.parity.includes('generatedFrom: "emulsify-wordpress"'), 'Sister-project parity doc should document generated child theme lineage metadata.');
+ ensure(docs.parity.includes('{% include "project_machine_name:component_name" %}') && docs.parity.includes('The legacy `@components/component-name/component-name.twig` namespace remains supported'), 'Sister-project parity doc should promote generic project machine-name component includes while preserving @components compatibility.');
+ ensure(docs.architecture.includes('The parent theme owns reusable runtime behavior'), 'Architecture doc should explain parent responsibilities.');
+ ensure(docs.architecture.includes('generatedFrom: "emulsify-wordpress"') && docs.architecture.includes('safer replacement checks'), 'Architecture doc should document generated child theme lineage metadata.');
+ ensure(docs.architecture.includes('@emulsify-tpl'), 'Architecture doc should document the parent-only template namespace.');
+ ensure(docs.architecture.includes('{% include "project_machine_name:component_name" %}') && docs.architecture.includes('The legacy `@components/component-name/component-name.twig` namespace remains supported'), 'Architecture doc should document generic project machine-name component includes while preserving @components compatibility.');
+ ensure(docs.architecture.includes('Optional persistent discovery cache') && docs.architecture.includes('clear_discovery_cache'), 'Architecture doc should document the optional component discovery cache and clear method.');
+ ensure(docs.twig.includes('@templates') && docs.twig.includes('@components'), 'Twig doc should document core namespaces.');
+ ensure(docs.twig.includes('Install or author project components in the structure defined by the selected Emulsify component system'), 'Twig doc should avoid prescribing a component source structure.');
+ ensure(docs.twig.includes('The general form is `project_machine_name:component_name`'), 'Twig doc should document the generic project component include form.');
+ ensure(docs.twig.includes('The legacy `@components/example-card/example-card.twig` namespace remains supported for compatible component libraries'), 'Twig doc should preserve @components compatibility language.');
+ ensure(docs.twig.includes('[Component recipes](component-recipes.md)'), 'Twig doc should link to component recipes.');
+ ensure(docs.twig.includes('emulsify_theme_context'), 'Twig doc should document context extension.');
+ ensure(docs.workflow.includes('Core 4, Vite, and Storybook commands'), 'Workflow doc should use the expected command heading.');
+ ensure(docs.workflow.includes('"platform": "wordpress"'), 'Workflow doc should explain the WordPress platform adapter.');
+ ensure(docs.workflow.includes('generatedFrom: "emulsify-wordpress"') && docs.workflow.includes('support diagnostics'), 'Core 4 workflow doc should document generated child theme lineage metadata.');
+ ensure(docs.workflow.includes('does not ship a concrete component library'), 'Core 4 workflow doc should describe Whisk as component-system agnostic.');
+ ensure(docs.workflow.includes('assets/images') && docs.workflow.includes('assets/icons'), 'Core 4 workflow doc should document generic starter asset directories.');
+ ensure(docs.workflow.includes('does not include a child `theme.json` by default'), 'Core 4 workflow doc should document the child theme.json convention.');
+ ensure(docs.workflow.includes('The following shape is an example of a compatible component, not files shipped by Whisk'), 'Core 4 workflow doc should keep component examples documentation-only.');
+ ensure(docs.workflow.includes('[Component recipes](component-recipes.md)'), 'Core 4 workflow doc should link to component recipes.');
+ ensure(docs.workflow.includes('{% include "project_machine_name:component_name" %}') && docs.workflow.includes('The legacy `@components/component-name/component-name.twig` namespace remains supported'), 'Core 4 workflow doc should promote generic project machine-name component includes while preserving @components compatibility.');
+ ensure(docs.componentRecipes.includes('not files that must ship in every starter'), 'Component recipes doc should keep examples documentation-only.');
+ ensure(docs.componentRecipes.includes('Do not add a full component library to `whisk/src/components`'), 'Component recipes doc should avoid adding active starter components to Whisk.');
+ ensure(docs.componentRecipes.includes('src/components/card/card.twig'), 'Component recipes doc should include a Twig component example.');
+ ensure(docs.componentRecipes.includes('src/components/card/card.stories.js'), 'Component recipes doc should include a Storybook story example.');
+ ensure(docs.componentRecipes.includes('src/components/card/card.component.json'), 'Component recipes doc should include an ACF/Twig component metadata example.');
+ ensure(docs.componentRecipes.includes('src/components/card/block.json'), 'Component recipes doc should include a native block metadata example.');
+ ensure(docs.componentRecipes.includes('patterns/card-feature.json'), 'Component recipes doc should include a pattern JSON example.');
+ ensure(docs.componentRecipes.includes('Use core block Twig rendering only'), 'Component recipes doc should explain core block Twig rendering use.');
+ ensure(docs.acfJson.includes('config/acf-json'), 'ACF Local JSON doc should document the child theme JSON path.');
+ ensure(docs.acfJson.includes('emulsify_theme_acf_json_save_path'), 'ACF Local JSON doc should document the save path filter.');
+ ensure(docs.acfJson.includes('commit ACF JSON files'), 'ACF Local JSON doc should tell project teams to commit ACF JSON.');
+ ensure(docs.acfBlocks.includes('Whisk does not include an active ACF/Twig block example'), 'ACF/Twig blocks doc should explain that starter metadata is documentation-only.');
+ ensure(docs.acfBlocks.includes('[Component recipes](component-recipes.md)'), 'ACF/Twig blocks doc should link to component recipes.');
+ ensure(docs.acfBlocks.includes('emulsify_theme_acf_block_args'), 'ACF/Twig blocks doc should document the block args filter.');
+ ensure(docs.acfBlocks.includes('Scoped assets') && docs.acfBlocks.includes('emulsify_theme_acf_block_asset_records'), 'ACF/Twig blocks doc should document scoped block assets.');
+ ensure(docs.nativeBlocks.includes('The starter does not include an active native block example'), 'Native blocks doc should avoid over-claiming a native example.');
+ ensure(docs.nativeBlocks.includes('[Component recipes](component-recipes.md)'), 'Native block doc should link to component recipes.');
+ ensure(docs.nativeBlocks.includes('emulsify_theme_native_block_directories'), 'Native blocks doc should document the native block directories filter.');
+ ensure(docs.nativeBlocks.includes('style`, `script`, `viewScript`') && docs.nativeBlocks.includes('register_block_type()'), 'Native blocks doc should document WordPress-owned block.json asset loading.');
+ ensure(docs.blockPatterns.includes('patterns/*.json'), 'Block patterns doc should document JSON pattern discovery.');
+ ensure(docs.blockPatterns.includes('patterns/categories.json') && docs.blockPatterns.includes('patterns/_categories.json'), 'Block patterns doc should document category metadata files.');
+ ensure(docs.blockPatterns.includes('[Component recipes](component-recipes.md)'), 'Block patterns doc should link to component recipes.');
+ ensure(docs.blockPatterns.includes('emulsify_theme_pattern_directories'), 'Block patterns doc should document directory filtering.');
+ ensure(docs.blockPatterns.includes('emulsify_theme_pattern_args'), 'Block patterns doc should document final args filtering.');
+ ensure(docs.editorEnhancements.includes('emulsify_theme_editor_enhancements_config'), 'Editor enhancements doc should document the config filter.');
+ ensure(docs.editorEnhancements.includes('columnsEqualHeight'), 'Editor enhancements doc should document the columns module.');
+ ensure(docs.editorEnhancements.includes('fileCaption'), 'Editor enhancements doc should document the file caption module.');
+ ensure(docs.editorEnhancements.includes('embedVariations'), 'Editor enhancements doc should document the embed variation module.');
+ ensure(docs.editorEnhancements.includes('placement'), 'Editor enhancements doc should document the placement module.');
+ ensure(docs.editorPolicy.includes('emulsify_theme_editor_policy_options'), 'Editor policy doc should document the options filter.');
+ ensure(docs.editorPolicy.includes('auto_allow_pattern_blocks'), 'Editor policy doc should document pattern JSON auto-allow behavior.');
+ ensure(docs.editorPolicy.includes('emulsify_theme_block_support_overrides'), 'Editor policy doc should document block support overrides.');
+ ensure(docs.assets.includes('emulsify_theme_asset_directories'), 'Asset loading doc should document asset directory filtering.');
+ ensure(docs.assets.includes('dist/emulsify-assets.json') && docs.assets.includes('emulsify_theme_asset_manifest_path'), 'Asset loading doc should document optional manifest loading.');
+ ensure(docs.assets.includes('block-scoped assets') && docs.assets.includes('emulsify_theme_acf_block_asset_records'), 'Asset loading doc should document block-scoped asset behavior.');
+ ensure(docs.assets.includes('Manifest data is memoized') && docs.assets.includes('Scanner fallback records are also memoized'), 'Asset loading doc should document per-request manifest and scanner memoization.');
+ ensure(docs.coreBlockTwig.includes('[Component recipes](component-recipes.md)'), 'Core block Twig rendering doc should link to component recipes.');
+ ensure(docs.cli.includes('--dry-run') && docs.cli.includes('--force') && docs.cli.includes('--activate'), 'WP-CLI doc should document generator safety options.');
+ ensure(docs.cli.includes('Force replacement safety') && docs.cli.includes('Emulsify-generated child theme markers'), 'WP-CLI doc should document force replacement safety.');
+ ensure(docs.cli.includes('Upgrade and support diagnostics') && docs.cli.includes('generatedFromVersion'), 'WP-CLI doc should document generated child theme lineage diagnostics.');
+ ensure(docs.cli.includes('Ignored dependency, cache, Storybook, and Vite output directories are not copied'), 'WP-CLI doc should explain that generated themes do not inherit build output.');
+ ensure(docs.release.includes('release-2.x') && docs.release.includes('2.0.0'), 'Release process doc should document the release-2.x target release.');
+ ensure(docs.release.includes('WordPress Theme Readiness workflow'), 'Release process doc should document the theme readiness workflow.');
+ ensure(docs.release.includes('Manual and scheduled runs execute the full WordPress fixture smoke test'), 'Release process doc should explain when the full fixture runs.');
+ ensure(docs.release.includes('Open GitHub Actions for `emulsify-ds/emulsify-wordpress`'), 'Release process doc should tell maintainers where to trigger the manual workflow.');
+ ensure(docs.release.includes('Keep `wordpress_fixture` enabled'), 'Release process doc should document the manual workflow fixture input.');
+ ensure(docs.release.includes('Success means both the `Practical theme readiness` job and the `WordPress fixture smoke` job pass'), 'Release process doc should define manual fixture success.');
+ ensure(docs.release.includes('generates and activates a child theme from Whisk'), 'Release process doc should document generated child fixture coverage.');
+ ensure(docs.release.includes('ACF/Twig and native `block.json` discovery'), 'Release process doc should document block discovery fixture coverage.');
+ ensure(docs.release.includes('WP_SMOKE_REQUIRED=1'), 'Release process doc should document required fixture smoke behavior.');
+ ensure(docs.release.includes('Manual dispatch can also run the Whisk Storybook build and accessibility audit'), 'Release process doc should document optional extended checks.');
+ ensure(docs.post2xRoadmap.includes('follow-up opportunities for focused minor releases, not 2.0 blockers'), 'Post-2.x roadmap should frame items as follow-up opportunities.');
+ ensure(docs.post2xRoadmap.includes('Ship 2.0 without adding new runtime features'), 'Post-2.x roadmap should keep 2.0 focused.');
+ ensure(docs.post2xRoadmap.includes('## Done in 2.0'), 'Post-2.x roadmap should separate completed 2.0 work from follow-up work.');
+ ensure(docs.post2xRoadmap.includes('Composer PSR-4 autoloading and grouped runtime directories'), 'Post-2.x roadmap should identify runtime class grouping as completed.');
+ ensure(docs.post2xRoadmap.includes('Optional manifest-driven asset loading') || docs.post2xRoadmap.includes('optional manifest-driven asset loading'), 'Post-2.x roadmap should identify asset manifest support as completed.');
+ ensure(docs.post2xRoadmap.includes('AssetRecord') && docs.post2xRoadmap.includes('AssetEnqueuer') && docs.post2xRoadmap.includes('Diagnostics'), 'Post-2.x roadmap should identify shared Support helpers as completed.');
+ ensure(docs.post2xRoadmap.includes('ProjectComponentLoader'), 'Post-2.x roadmap should identify the named Twig project component loader as completed.');
+ ensure(docs.post2xRoadmap.includes('wp emulsify doctor'), 'Post-2.x roadmap should include CLI diagnostics.');
+ ensure(docs.post2xRoadmap.includes('PHPUnit'), 'Post-2.x roadmap should include migration of smoke coverage into PHPUnit or an equivalent WordPress-aware layer.');
+ ensure(docs.post2xRoadmap.includes('block.json-based ACF registration'), 'Post-2.x roadmap should include block.json-based ACF registration as a follow-up.');
+ ensure(docs.post2xRoadmap.includes('optional persistent discovery caching') && docs.post2xRoadmap.includes('invalidation guidance'), 'Post-2.x roadmap should keep cache follow-up work focused on diagnostics and guidance.');
+ for (const heading of [
+ '## Done in 2.0',
+ '## Code organization',
+ '## Runtime architecture',
+ '## Asset loading and performance',
+ '## Component/block discovery',
+ '## CLI diagnostics',
+ '## Editor and block feature modules',
+ '## Documentation and support tooling',
+ ]) {
+ ensure(docs.post2xRoadmap.includes(heading), `Post-2.x roadmap should include ${heading}.`);
+ }
+ for (const docLink of [
+ 'upgrading-1x-to-2x.md',
+ 'parent-child-architecture.md',
+ 'wp-cli-child-theme-generation.md',
+ 'timber-and-twig-authoring.md',
+ 'component-recipes.md',
+ 'core-4-vite-workflow.md',
+ 'acf-twig-blocks.md',
+ 'native-gutenberg-blocks.md',
+ 'core-block-twig-rendering.md',
+ 'block-patterns.md',
+ 'editor-policy.md',
+ 'editor-enhancements.md',
+ 'acf-local-json.md',
+ 'asset-loading.md',
+ 'release-process.md',
+ 'sister-project-parity.md',
+ 'post-2x-optimization-roadmap.md',
+ ]) {
+ const matches = docs.index.split(`(${docLink})`).length - 1;
+ ensure(matches === 1, `Docs index should link to ${docLink} exactly once.`);
+ }
+ ensure(pullRequestTemplate.includes('2.0 release branch merge') && pullRequestTemplate.includes('wordpress_fixture'), 'PR template should include the 2.0 manual fixture checklist item.');
+ ensure(/duplicate[\w\s/`.-]*skipped instead of being registered twice/i.test(docsText), 'Docs should document duplicate block handling.');
+ ensure(docsText.includes('normal frontend visitors') || docsText.includes('Normal frontend visitors'), 'Docs should document that duplicate diagnostics avoid frontend noise.');
+ ensure(!/Webpack/i.test(`${readme}\n${docsText}`), 'Docs should not mention Webpack.');
+ ensure(!incorrectWordPressPattern.test(`${readme}\n${docsText}`), 'Docs should use the canonical WordPress spelling.');
+ ensure(issueTemplate.includes('emulsify-wordpress/releases'), 'Issue template should link to WordPress theme releases.');
+ ensure(pullRequestTemplate.includes('emulsify-wordpress/issues/1'), 'Pull request template should link to WordPress theme issues.');
+ ensure(!/emulsify-drupal/.test(`${issueTemplate}\n${pullRequestTemplate}`), 'GitHub templates should not link to the Drupal repository.');
+ return 'README, docs, and GitHub templates match the WordPress 2.0.0 release story.';
+ });
+
+ runStaticCheck('License metadata', () => {
+ ensureGpl2LicenseText('LICENSE', license);
+ ensure(!fs.existsSync(path.join(repoRoot, 'LICENSE.txt')), 'LICENSE.txt should not duplicate the canonical LICENSE file.');
+ ensure(!/MIT License/i.test(readme), 'README.md should not document an MIT license.');
+ return 'License files and project metadata align on GPL-2.0-only.';
+ });
+
+ runStaticCheck('No Drupal.org workflow references', () => {
+ const forbiddenPatterns = [
+ /Drupal\.org/i,
+ /drupal-org/i,
+ /DRUPAL_ORG/,
+ /DRUPAL_REPO_URL/,
+ /shimataro\/ssh-key-action/,
+ /SSH_CONFIG/,
+ /KNOWN_HOSTS/,
+ ];
+ const files = listFilesRecursive('.github/workflows', (file) => /\.ya?ml$/.test(file));
+ const violations = [];
+
+ for (const file of files) {
+ const contents = readFile(file);
+ for (const pattern of forbiddenPatterns) {
+ if (pattern.test(contents)) {
+ violations.push(`${file} matches ${pattern}`);
+ }
+ }
+ }
+
+ ensure(violations.length === 0, `Remove Drupal.org workflow references:\n${violations.join('\n')}`);
+ return 'No Drupal.org sync workflow references or stale Drupal.org secrets remain.';
+ });
+
+ runStaticCheck('No .DS_Store files', () => {
+ const dsStoreFiles = listFilesRecursive('.', (file) => path.basename(file) === '.DS_Store')
+ .filter((file) => !file.startsWith('.git/'));
+ ensure(dsStoreFiles.length === 0, `.DS_Store files found outside .git: ${dsStoreFiles.join(', ')}.`);
+ return 'No .DS_Store files were found outside .git.';
+ });
+
+ runCommandCheck('WordPress fixture smoke', process.execPath, ['.github/scripts/wordpress-fixture-smoke.cjs']);
+}
+
+function printSummary() {
+ console.log('\nRelease Check Summary');
+ for (const result of results) {
+ console.log(`${result.status.padEnd(4)} ${result.name}: ${result.detail}`);
+ }
+}
+
+runStaticChecks();
+printSummary();
+
+const hasBlockingFailure = results.some((result) => result.status === 'FAIL');
+process.exit(hasBlockingFailure ? 1 : 0);
diff --git a/.github/scripts/theme-filters-smoke.php b/.github/scripts/theme-filters-smoke.php
new file mode 100644
index 0000000..240f72f
--- /dev/null
+++ b/.github/scripts/theme-filters-smoke.php
@@ -0,0 +1,531 @@
+ $accepted_args,
+ 'callback' => $callback,
+ );
+
+ ksort( $GLOBALS['emulsify_filter_smoke_filters'][ $hook ] );
+
+ return true;
+ }
+}
+
+if ( ! function_exists( 'apply_filters' ) ) {
+ function apply_filters( string $hook, $value, ...$arguments ) {
+ if ( empty( $GLOBALS['emulsify_filter_smoke_filters'][ $hook ] ) ) {
+ return $value;
+ }
+
+ foreach ( $GLOBALS['emulsify_filter_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_filter_smoke_child'];
+ }
+}
+
+if ( ! function_exists( 'get_template_directory' ) ) {
+ function get_template_directory(): string {
+ return $GLOBALS['emulsify_filter_smoke_parent'];
+ }
+}
+
+if ( ! function_exists( 'get_stylesheet_directory_uri' ) ) {
+ function get_stylesheet_directory_uri(): string {
+ return 'https://example.test/wp-content/themes/child';
+ }
+}
+
+if ( ! function_exists( 'get_template_directory_uri' ) ) {
+ function get_template_directory_uri(): string {
+ return 'https://example.test/wp-content/themes/parent';
+ }
+}
+
+if ( ! function_exists( 'sanitize_key' ) ) {
+ function sanitize_key( string $key ): string {
+ return strtolower( preg_replace( '/[^a-zA-Z0-9_-]+/', '', $key ) );
+ }
+}
+
+if ( ! function_exists( 'sanitize_title' ) ) {
+ function sanitize_title( string $title ): string {
+ return trim( preg_replace( '/[^a-z0-9]+/', '-', strtolower( $title ) ), '-' );
+ }
+}
+
+if ( ! function_exists( 'wp_enqueue_style' ) ) {
+ function wp_enqueue_style( string $handle, string $src, array $deps = array(), $version = false ): void {
+ $GLOBALS['emulsify_filter_smoke_styles'][ $handle ] = array(
+ 'src' => $src,
+ 'version' => $version,
+ );
+ }
+}
+
+if ( ! function_exists( 'load_theme_textdomain' ) ) {
+ function load_theme_textdomain( string $domain, string $path ): bool {
+ $GLOBALS['emulsify_filter_smoke_textdomain'] = compact( 'domain', 'path' );
+
+ return true;
+ }
+}
+
+if ( ! function_exists( 'add_theme_support' ) ) {
+ function add_theme_support( string $feature, $args = true ): void {
+ $GLOBALS['emulsify_filter_smoke_theme_support'][ $feature ] = $args;
+ }
+}
+
+if ( ! function_exists( 'add_image_size' ) ) {
+ function add_image_size( string $name, int $width = 0, int $height = 0, $crop = false ): void {
+ $GLOBALS['emulsify_filter_smoke_image_sizes'][ $name ] = compact( 'width', 'height', 'crop' );
+ }
+}
+
+if ( ! function_exists( 'wp_get_theme' ) ) {
+ function wp_get_theme() {
+ return new class() {
+ public function get( string $key ) {
+ $values = array(
+ 'Name' => 'Smoke Child',
+ 'Version' => '1.0.0',
+ 'TextDomain' => 'smoke-child',
+ );
+
+ return $values[ $key ] ?? '';
+ }
+
+ public function parent() {
+ return null;
+ }
+ };
+ }
+}
+
+if ( ! function_exists( 'get_body_class' ) ) {
+ function get_body_class(): array {
+ return array( 'filter-smoke' );
+ }
+}
+
+if ( ! function_exists( 'admin_url' ) ) {
+ function admin_url( string $path = '' ): string {
+ return 'https://example.test/wp-admin/' . ltrim( $path, '/' );
+ }
+}
+
+if ( ! function_exists( 'home_url' ) ) {
+ function home_url( string $path = '' ): string {
+ return 'https://example.test/' . ltrim( $path, '/' );
+ }
+}
+
+if ( ! function_exists( 'site_url' ) ) {
+ function site_url( string $path = '' ): string {
+ return 'https://example.test/' . ltrim( $path, '/' );
+ }
+}
+
+foreach ( array( 'is_404', 'is_archive', 'is_front_page', 'is_home', 'is_page', 'is_search', 'is_single', 'is_singular', 'is_user_logged_in' ) as $function ) {
+ if ( ! function_exists( $function ) ) {
+ eval( 'function ' . $function . '(): bool { return false; }' );
+ }
+}
+
+if ( ! function_exists( 'wp_login_url' ) ) {
+ function wp_login_url(): string {
+ return 'https://example.test/wp-login.php';
+ }
+}
+
+if ( ! function_exists( 'wp_logout_url' ) ) {
+ function wp_logout_url(): string {
+ return 'https://example.test/wp-login.php?action=logout';
+ }
+}
+
+if ( ! function_exists( 'rest_url' ) ) {
+ function rest_url(): string {
+ return 'https://example.test/wp-json/';
+ }
+}
+
+if ( ! function_exists( 'get_search_query' ) ) {
+ function get_search_query(): string {
+ return '';
+ }
+}
+
+if ( ! function_exists( 'get_theme_file_path' ) ) {
+ function get_theme_file_path( string $path = '' ): string {
+ return get_stylesheet_directory() . '/' . ltrim( $path, '/' );
+ }
+}
+
+if ( ! function_exists( 'wp_get_current_user' ) ) {
+ function wp_get_current_user() {
+ return null;
+ }
+}
+
+if ( ! function_exists( 'acf_register_block_type' ) ) {
+ function acf_register_block_type( array $args ) {
+ $GLOBALS['emulsify_filter_smoke_acf_blocks'][] = $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_filter_smoke_native_blocks'][] = $path;
+
+ return $path;
+ }
+}
+
+/**
+ * Fails the smoke script when an assertion is false.
+ *
+ * @param bool $condition Assertion condition.
+ * @param string $message Failure message.
+ * @return void
+ */
+function emulsify_filter_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_filter_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_filter_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-theme-filters-' . uniqid( '', true );
+$child = $work_root . '/child-theme';
+$parent = $work_root . '/parent-theme';
+$extra = $work_root . '/extra';
+
+$GLOBALS['emulsify_filter_smoke_child'] = $child;
+$GLOBALS['emulsify_filter_smoke_parent'] = $parent;
+
+try {
+ emulsify_filter_smoke_write( $child . '/dist/global/child.css', 'body { color: black; }' );
+ emulsify_filter_smoke_write( $child . '/dist/global/editor/css/index.css', '.editor-only { display: block; }' );
+ emulsify_filter_smoke_write( $parent . '/dist/global/child.css', 'body { color: parent; }' );
+ emulsify_filter_smoke_write( $extra . '/assets/extra.css', 'body { color: red; }' );
+ emulsify_filter_smoke_write( $extra . '/twig/example.twig', 'Example' );
+ emulsify_filter_smoke_write( $extra . '/components/filter-card/filter-card.component.json', '{"title":"Filter Card"}' );
+ emulsify_filter_smoke_write( $extra . '/components/filter-card/filter-card.twig', 'Filter card ' );
+ emulsify_filter_smoke_write( $extra . '/native/block.json', '{"name":"emulsify/filter-native"}' );
+
+ require_once $repo_root . '/includes/Support/AttributeBag.php';
+ 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/Support/ProjectConfig.php';
+ require_once $repo_root . '/includes/Runtime/Assets.php';
+ require_once $repo_root . '/includes/Runtime/Context.php';
+ require_once $repo_root . '/includes/Runtime/Setup.php';
+ require_once $repo_root . '/includes/Runtime/Twig.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_asset_directories',
+ static function ( array $directories, string $directory ) use ( $extra ): array {
+ if ( 'dist/global' === $directory ) {
+ $directories[] = array(
+ 'path' => $extra . '/assets',
+ 'priority' => 0,
+ 'source' => 'smoke',
+ 'uri' => 'https://example.test/extra/assets',
+ );
+ }
+
+ return $directories;
+ },
+ 10,
+ 2
+ );
+
+ add_filter(
+ 'emulsify_theme_asset_files',
+ static function ( array $assets, string $directory, array $extensions ): array {
+ if ( 'dist/global' === $directory && in_array( 'css', $extensions, true ) ) {
+ $assets[] = array(
+ 'path' => __FILE__,
+ 'priority' => 0,
+ 'relative' => 'filtered.css',
+ 'uri' => 'https://example.test/filtered.css',
+ 'version' => 'filtered',
+ );
+ }
+
+ return $assets;
+ },
+ 10,
+ 3
+ );
+
+ add_filter(
+ 'emulsify_theme_twig_namespaces',
+ static function ( array $namespaces ) use ( $extra ): array {
+ $namespaces[] = array(
+ 'namespace' => 'project',
+ 'path' => $extra . '/twig',
+ );
+
+ return $namespaces;
+ }
+ );
+
+ add_filter(
+ 'emulsify_theme_context',
+ static function ( array $context ): array {
+ $context['project_flag'] = true;
+
+ return $context;
+ }
+ );
+
+ add_filter(
+ 'emulsify_theme_setup_options',
+ static function ( array $options ): array {
+ $options['theme_supports']['custom-spacing'] = true;
+ $options['image_sizes']['smoke-card'] = array(
+ 'width' => 320,
+ 'height' => 180,
+ 'crop' => true,
+ );
+
+ return $options;
+ }
+ );
+
+ add_filter(
+ 'emulsify_theme_component_roots',
+ static function ( array $roots ) use ( $extra ): array {
+ $roots[] = array(
+ 'path' => $extra . '/components',
+ 'source' => 'smoke',
+ );
+
+ return $roots;
+ }
+ );
+
+ add_filter(
+ 'emulsify_theme_acf_block_metadata',
+ static function ( array $metadata ): array {
+ $metadata['title'] = 'Filtered Card';
+
+ return $metadata;
+ }
+ );
+
+ add_filter(
+ 'emulsify_theme_acf_block_args',
+ static function ( array $args ): array {
+ $args['name'] = 'emulsify/filtered-card';
+ $args['category'] = 'design';
+
+ return $args;
+ }
+ );
+
+ add_filter(
+ 'emulsify_theme_native_block_directories',
+ static function ( array $directories ) use ( $extra ): array {
+ $directories[] = array(
+ 'path' => $extra . '/native',
+ 'relative' => 'native',
+ 'source' => 'smoke',
+ 'metadata_path' => $extra . '/native/block.json',
+ 'name' => 'emulsify/filter-native',
+ );
+
+ return $directories;
+ }
+ );
+
+ ( new Emulsify\Theme\Runtime\Assets() )->styles();
+
+ emulsify_filter_smoke_assert(
+ 'https://example.test/wp-content/themes/child/dist/global/child.css' === $GLOBALS['emulsify_filter_smoke_styles']['emulsify-global-child']['src'],
+ 'Asset discovery should keep child roots before parent fallback roots for duplicate relative paths.'
+ );
+ emulsify_filter_smoke_assert(
+ isset( $GLOBALS['emulsify_filter_smoke_styles']['emulsify-global-extra'] ),
+ 'Asset directory filter should add an extra CSS asset root.'
+ );
+ emulsify_filter_smoke_assert(
+ isset( $GLOBALS['emulsify_filter_smoke_styles']['emulsify-global-filtered'] ),
+ 'Asset files filter should add a filtered CSS asset.'
+ );
+ emulsify_filter_smoke_assert(
+ empty(
+ array_filter(
+ array_keys( $GLOBALS['emulsify_filter_smoke_styles'] ),
+ static function ( string $handle ): bool {
+ return false !== strpos( $handle, 'editor' );
+ }
+ )
+ ),
+ 'Generic asset loading should skip editor-only global assets.'
+ );
+
+ $loader = new class() {
+ public $paths = array();
+
+ public function addPath( string $path, string $namespace ): void {
+ $this->paths[] = compact( 'path', 'namespace' );
+ }
+ };
+
+ ( new Emulsify\Theme\Runtime\Twig() )->loader_paths( $loader );
+
+ emulsify_filter_smoke_assert(
+ in_array( array( 'path' => $extra . '/twig', 'namespace' => 'project' ), $loader->paths, true ),
+ 'Twig namespace filter should add a project namespace.'
+ );
+
+ $context = ( new Emulsify\Theme\Runtime\Context() )->add( array() );
+
+ emulsify_filter_smoke_assert(
+ true === $context['project_flag'],
+ 'Context filter should add project context values.'
+ );
+
+ ( new Emulsify\Theme\Runtime\Setup() )->theme_supports();
+
+ emulsify_filter_smoke_assert(
+ array_key_exists( 'custom-spacing', $GLOBALS['emulsify_filter_smoke_theme_support'] ),
+ 'Setup options filter should add custom theme support.'
+ );
+ emulsify_filter_smoke_assert(
+ array_key_exists( 'smoke-card', $GLOBALS['emulsify_filter_smoke_image_sizes'] ),
+ 'Setup options filter should add custom image sizes.'
+ );
+
+ $locator = new Emulsify\Theme\Blocks\ComponentLocator();
+
+ emulsify_filter_smoke_assert(
+ in_array( 'filter-card', array_column( $locator->acf_components(), 'relative' ), true ),
+ 'Component roots filter should add an extra component discovery root.'
+ );
+
+ ( new Emulsify\Theme\Blocks\AcfBlocks( new Emulsify\Theme\Blocks\ComponentLocator() ) )->register_blocks();
+
+ emulsify_filter_smoke_assert(
+ 'emulsify-filtered-card' === $GLOBALS['emulsify_filter_smoke_acf_blocks'][0]['name'],
+ 'ACF block args filter should alter and normalize the final block name.'
+ );
+ emulsify_filter_smoke_assert(
+ 'Filtered Card' === $GLOBALS['emulsify_filter_smoke_acf_blocks'][0]['title'],
+ 'ACF block metadata filter should alter metadata before defaults are merged.'
+ );
+
+ ( new Emulsify\Theme\Blocks\NativeBlocks( new Emulsify\Theme\Blocks\ComponentLocator() ) )->register_blocks();
+
+ emulsify_filter_smoke_assert(
+ in_array( $extra . '/native', $GLOBALS['emulsify_filter_smoke_native_blocks'], true ),
+ 'Native block directories filter should add a native block directory.'
+ );
+
+ echo "Theme filter smoke checks passed.\n";
+} catch ( Throwable $throwable ) {
+ fwrite( STDERR, $throwable->getMessage() . "\n" );
+ exit( 1 );
+} finally {
+ emulsify_filter_smoke_remove( $work_root );
+}
diff --git a/.github/scripts/twig-project-namespace-smoke.php b/.github/scripts/twig-project-namespace-smoke.php
new file mode 100644
index 0000000..a9fbb59
--- /dev/null
+++ b/.github/scripts/twig-project-namespace-smoke.php
@@ -0,0 +1,332 @@
+ $accepted_args,
+ 'callback' => $callback,
+ );
+
+ ksort( $GLOBALS['emulsify_twig_project_smoke_filters'][ $hook ] );
+
+ return true;
+ }
+}
+
+if ( ! function_exists( 'apply_filters' ) ) {
+ function apply_filters( string $hook, $value, ...$arguments ) {
+ if ( empty( $GLOBALS['emulsify_twig_project_smoke_filters'][ $hook ] ) ) {
+ return $value;
+ }
+
+ foreach ( $GLOBALS['emulsify_twig_project_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_twig_project_smoke_child'];
+ }
+}
+
+if ( ! function_exists( 'get_template_directory' ) ) {
+ function get_template_directory(): string {
+ return $GLOBALS['emulsify_twig_project_smoke_parent'];
+ }
+}
+
+require_once $repo_root . '/includes/Support/AttributeBag.php';
+require_once $repo_root . '/includes/Support/FileDiscovery.php';
+require_once $repo_root . '/includes/Support/ProjectConfig.php';
+require_once $repo_root . '/includes/Twig/ProjectComponentLoader.php';
+require_once $repo_root . '/includes/Runtime/Twig.php';
+
+/**
+ * Fails the smoke script when an assertion is false.
+ *
+ * @param bool $condition Assertion condition.
+ * @param string $message Failure message.
+ * @return void
+ */
+function emulsify_twig_project_smoke_assert( bool $condition, string $message ): void {
+ if ( ! $condition ) {
+ throw new RuntimeException( $message );
+ }
+}
+
+/**
+ * Asserts strict equality.
+ *
+ * @param mixed $expected Expected value.
+ * @param mixed $actual Actual value.
+ * @param string $message Failure message.
+ * @return void
+ */
+function emulsify_twig_project_smoke_assert_same( $expected, $actual, string $message ): void {
+ if ( $expected !== $actual ) {
+ throw new RuntimeException(
+ sprintf(
+ "%s\nExpected: %s\nActual: %s",
+ $message,
+ var_export( $expected, true ),
+ var_export( $actual, true )
+ )
+ );
+ }
+}
+
+/**
+ * Writes a fixture file.
+ *
+ * @param string $path File path.
+ * @param string $contents File contents.
+ * @return void
+ */
+function emulsify_twig_project_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_twig_project_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 );
+}
+
+/**
+ * Builds a Twig environment for a child and parent theme pair.
+ *
+ * @param string $child Child theme path.
+ * @param string $parent Parent theme path.
+ * @return \Twig\Environment Twig environment.
+ */
+function emulsify_twig_project_smoke_environment( string $child, string $parent ): \Twig\Environment {
+ $GLOBALS['emulsify_twig_project_smoke_child'] = $child;
+ $GLOBALS['emulsify_twig_project_smoke_parent'] = $parent;
+
+ $loader = new \Twig\Loader\FilesystemLoader();
+ $loader = ( new \Emulsify\Theme\Runtime\Twig() )->loader_paths( $loader );
+
+ return new \Twig\Environment(
+ $loader,
+ array(
+ 'autoescape' => false,
+ 'cache' => false,
+ )
+ );
+}
+
+/**
+ * Renders an inline Twig template.
+ *
+ * @param \Twig\Environment $environment Twig environment.
+ * @param string $template Inline Twig template.
+ * @return string Rendered output.
+ */
+function emulsify_twig_project_smoke_render( \Twig\Environment $environment, string $template ): string {
+ return $environment->createTemplate( $template )->render();
+}
+
+$work_root = sys_get_temp_dir() . '/emulsify-twig-project-namespace-' . uniqid( '', true );
+$child = $work_root . '/child-theme';
+$missing_child = $work_root . '/missing-project-child';
+$invalid_child = $work_root . '/invalid-project-child';
+$parent = $work_root . '/parent-theme';
+$extra = $work_root . '/extra-components';
+
+try {
+ emulsify_twig_project_smoke_write(
+ $child . '/project.emulsify.json',
+ json_encode(
+ array(
+ 'project' => array(
+ 'machineName' => 'whisk',
+ ),
+ 'variant' => array(
+ 'structureImplementations' => array(
+ array(
+ 'name' => 'custom',
+ 'directory' => 'custom-twig',
+ ),
+ array(
+ 'name' => '@unsafe',
+ 'directory' => '../outside',
+ ),
+ ),
+ ),
+ ),
+ JSON_UNESCAPED_SLASHES
+ )
+ );
+ emulsify_twig_project_smoke_write( $child . '/src/components/example-card/example-card.twig', 'Child example card' );
+ emulsify_twig_project_smoke_write( $child . '/src/components/badge.twig', 'Child badge' );
+ emulsify_twig_project_smoke_write( $child . '/src/components/ui/heading/heading.twig', 'Child heading' );
+ emulsify_twig_project_smoke_write( $child . '/components/link/link.twig', 'Child legacy link' );
+ emulsify_twig_project_smoke_write( $child . '/custom-twig/teaser.twig', 'Configured namespace teaser' );
+ emulsify_twig_project_smoke_write( $parent . '/src/components/example-card/example-card.twig', 'Parent example card' );
+ emulsify_twig_project_smoke_write( $extra . '/filtered/filtered.twig', 'Filtered root' );
+
+ add_filter(
+ 'emulsify_theme_project_component_roots',
+ static function ( array $roots, string $machine_name ) use ( $extra ): array {
+ if ( 'whisk' === $machine_name ) {
+ $roots[] = $extra;
+ }
+
+ return $roots;
+ },
+ 10,
+ 2
+ );
+
+ $environment = emulsify_twig_project_smoke_environment( $child, $parent );
+
+ emulsify_twig_project_smoke_assert_same(
+ 'Child example card',
+ emulsify_twig_project_smoke_render( $environment, '{% include "whisk:example-card" %}' ),
+ 'machineName:component should resolve to the child src component template.'
+ );
+
+ emulsify_twig_project_smoke_assert_same(
+ 'Child example card',
+ emulsify_twig_project_smoke_render( $environment, '{% include "@components/example-card/example-card.twig" %}' ),
+ '@components should still resolve child components.'
+ );
+
+ emulsify_twig_project_smoke_assert_same(
+ 'Configured namespace teaser',
+ emulsify_twig_project_smoke_render( $environment, '{% include "@custom/teaser.twig" %}' ),
+ 'variant.structureImplementations should register matching Twig namespaces.'
+ );
+
+ emulsify_twig_project_smoke_assert_same(
+ 'Child badge',
+ emulsify_twig_project_smoke_render( $environment, '{% include "whisk:badge" %}' ),
+ 'machineName:component should support root-level component.twig candidates.'
+ );
+
+ emulsify_twig_project_smoke_assert_same(
+ 'Child heading',
+ emulsify_twig_project_smoke_render( $environment, '{% include "whisk:ui/heading" %}' ),
+ 'machineName:component should support one-level grouped component folders.'
+ );
+
+ emulsify_twig_project_smoke_assert_same(
+ 'Child legacy link',
+ emulsify_twig_project_smoke_render( $environment, '{% include "whisk:link" %}' ),
+ 'machineName:component should support legacy child components roots.'
+ );
+
+ emulsify_twig_project_smoke_assert_same(
+ 'Filtered root',
+ emulsify_twig_project_smoke_render( $environment, '{% include "whisk:filtered" %}' ),
+ 'Project component roots should be filterable.'
+ );
+
+ emulsify_twig_project_smoke_assert(
+ false === $environment->getLoader()->exists( 'whisk:../example-card' ),
+ 'Unsafe project component references should be rejected.'
+ );
+
+ emulsify_twig_project_smoke_write( $missing_child . '/src/components/example-card/example-card.twig', 'Missing project example card' );
+
+ $missing_environment = emulsify_twig_project_smoke_environment( $missing_child, $parent );
+
+ emulsify_twig_project_smoke_assert_same(
+ 'Missing project example card',
+ emulsify_twig_project_smoke_render( $missing_environment, '{% include "@components/example-card/example-card.twig" %}' ),
+ 'Missing project.emulsify.json should not break @components.'
+ );
+
+ emulsify_twig_project_smoke_assert(
+ false === $missing_environment->getLoader()->exists( 'whisk:example-card' ),
+ 'Missing project.emulsify.json should not register a project component loader.'
+ );
+
+ emulsify_twig_project_smoke_write( $invalid_child . '/project.emulsify.json', '{"project":' );
+ emulsify_twig_project_smoke_write( $invalid_child . '/src/components/example-card/example-card.twig', 'Invalid project example card' );
+
+ $invalid_environment = emulsify_twig_project_smoke_environment( $invalid_child, $parent );
+
+ emulsify_twig_project_smoke_assert_same(
+ 'Invalid project example card',
+ emulsify_twig_project_smoke_render( $invalid_environment, '{% include "@components/example-card/example-card.twig" %}' ),
+ 'Invalid project.emulsify.json should not break @components.'
+ );
+
+ echo "Twig project namespace smoke checks passed.\n";
+} catch ( Throwable $throwable ) {
+ fwrite( STDERR, $throwable->getMessage() . "\n" );
+ exit( 1 );
+} finally {
+ emulsify_twig_project_smoke_remove( $work_root );
+}
diff --git a/.github/scripts/twig-switch-smoke.php b/.github/scripts/twig-switch-smoke.php
new file mode 100644
index 0000000..ecd09b3
--- /dev/null
+++ b/.github/scripts/twig-switch-smoke.php
@@ -0,0 +1,79 @@
+ <<<'TWIG'
+{% switch value %}{% case 'alpha' or 'beta' %}matched{% default %}default{% endswitch %}
+TWIG,
+ 'default' => <<<'TWIG'
+{% switch value %}{% case 'alpha' %}matched{% default %}default{% endswitch %}
+TWIG,
+ 'break' => <<<'TWIG'
+{% switch value %}{% case 'beta' %}first{% case 'beta' %}second{% default %}default{% endswitch %}
+TWIG,
+ 'expression' => <<<'TWIG'
+{% switch value %}{% case ('alpha' ~ suffix) or secondary %}expression{% default %}default{% endswitch %}
+TWIG,
+ 'loose' => <<<'TWIG'
+{% switch value %}{% case '2' %}numeric{% default %}default{% endswitch %}
+TWIG,
+ )
+ ),
+ array(
+ 'autoescape' => false,
+ 'cache' => false,
+ 'use_yield' => true,
+ )
+);
+
+$environment = ( new TwigIntegration() )->extensions( $environment );
+
+emulsify_twig_switch_assert_same( 'multiple case values', 'matched', trim( $environment->render( 'multiple', array( 'value' => 'beta' ) ) ) );
+emulsify_twig_switch_assert_same( 'default branch', 'default', trim( $environment->render( 'default', array( 'value' => 'gamma' ) ) ) );
+emulsify_twig_switch_assert_same( 'automatic case break', 'first', trim( $environment->render( 'break', array( 'value' => 'beta' ) ) ) );
+emulsify_twig_switch_assert_same(
+ 'case expressions',
+ 'expression',
+ trim(
+ $environment->render(
+ 'expression',
+ array(
+ 'secondary' => 'beta',
+ 'suffix' => '-card',
+ 'value' => 'alpha-card',
+ )
+ )
+ )
+);
+emulsify_twig_switch_assert_same( 'PHP-style scalar matching', 'numeric', trim( $environment->render( 'loose', array( 'value' => 2 ) ) ) );
+
+echo "Twig switch smoke checks passed.\n";
diff --git a/.github/scripts/wordpress-fixture-smoke.cjs b/.github/scripts/wordpress-fixture-smoke.cjs
new file mode 100755
index 0000000..cee8b0e
--- /dev/null
+++ b/.github/scripts/wordpress-fixture-smoke.cjs
@@ -0,0 +1,824 @@
+#!/usr/bin/env node
+
+// End-to-end WordPress smoke fixture. It installs a disposable WordPress site,
+// mounts this parent theme, generates a Whisk child theme, and exercises the
+// runtime features that cannot be proven with pure PHP stubs.
+
+const childProcess = require('child_process');
+const fs = require('fs');
+const net = require('net');
+const os = require('os');
+const path = require('path');
+
+const repoRoot = path.resolve(__dirname, '../..');
+const required = process.env.WP_SMOKE_REQUIRED === '1';
+const keepFixture = process.env.WP_SMOKE_KEEP === '1';
+const host = process.env.WP_SMOKE_HOST || '127.0.0.1';
+const wordpressVersion = process.env.WP_SMOKE_WORDPRESS_VERSION || '6.7';
+let workDir = null;
+let logDir = null;
+let server = null;
+let databaseName = null;
+let currentDatabaseEnv = null;
+
+function log(message) {
+ console.log(`[wordpress-smoke] ${message}`);
+}
+
+function unavailable(message) {
+ if (required) {
+ throw new Error(message);
+ }
+
+ console.log(`WORDPRESS_SMOKE_SKIPPED ${message}`);
+ process.exit(0);
+}
+
+function commandExists(command) {
+ const result = childProcess.spawnSync('sh', ['-lc', `command -v ${command}`], {
+ encoding: 'utf8',
+ });
+
+ return result.status === 0;
+}
+
+function appendLog(file, contents) {
+ if (!logDir || !contents) {
+ return;
+ }
+
+ fs.appendFileSync(path.join(logDir, file), contents);
+}
+
+function run(label, command, args, options = {}) {
+ appendLog('commands.log', `\n$ ${[command, ...args].join(' ')}\n`);
+
+ const result = childProcess.spawnSync(command, args, {
+ cwd: options.cwd || repoRoot,
+ encoding: 'utf8',
+ env: options.env || process.env,
+ input: options.input,
+ maxBuffer: 1024 * 1024 * 20,
+ });
+
+ appendLog('commands.log', result.stdout);
+ appendLog('commands.log', result.stderr);
+
+ if (result.status !== 0) {
+ throw new Error(`${label} failed with exit code ${result.status}. See ${path.join(logDir, 'commands.log')}.`);
+ }
+
+ return result.stdout.trim();
+}
+
+function wp(wpPath, args, options = {}) {
+ return run(`wp ${args.join(' ')}`, 'wp', ['--allow-root', `--path=${wpPath}`, ...args], options);
+}
+
+function ensureTools() {
+ for (const command of ['php', 'composer', 'npm', 'wp']) {
+ if (!commandExists(command)) {
+ unavailable(`Required command "${command}" is not available.`);
+ }
+ }
+
+ const mysqli = childProcess.spawnSync('php', ['-r', 'exit(extension_loaded("mysqli") ? 0 : 1);']);
+ if (mysqli.status !== 0) {
+ unavailable('The PHP mysqli extension is required for the WordPress fixture database.');
+ }
+
+ if (!required && !process.env.WP_SMOKE_DB_HOST && !process.env.WP_SMOKE_DB_NAME) {
+ unavailable('Database environment is not configured. Set WP_SMOKE_DB_HOST, WP_SMOKE_DB_NAME, WP_SMOKE_DB_USER, and WP_SMOKE_DB_PASSWORD to run locally.');
+ }
+}
+
+function databaseEnv() {
+ const uniqueName = `emulsify_smoke_${process.pid}_${Date.now()}`;
+ databaseName = process.env.WP_SMOKE_DB_NAME || uniqueName;
+
+ if (!/^[A-Za-z0-9_]+$/.test(databaseName)) {
+ throw new Error('WP_SMOKE_DB_NAME may only contain letters, numbers, and underscores.');
+ }
+
+ return {
+ ...process.env,
+ WP_SMOKE_DB_HOST: process.env.WP_SMOKE_DB_HOST || '127.0.0.1',
+ WP_SMOKE_DB_PORT: process.env.WP_SMOKE_DB_PORT || '3306',
+ WP_SMOKE_DB_NAME: databaseName,
+ WP_SMOKE_DB_USER: process.env.WP_SMOKE_DB_USER || 'root',
+ WP_SMOKE_DB_PASSWORD: process.env.WP_SMOKE_DB_PASSWORD || 'root',
+ };
+}
+
+function createDatabase(env) {
+ const code = String.raw`
+$host = getenv('WP_SMOKE_DB_HOST') ?: '127.0.0.1';
+$port = (int) (getenv('WP_SMOKE_DB_PORT') ?: 3306);
+$user = getenv('WP_SMOKE_DB_USER') ?: 'root';
+$pass = getenv('WP_SMOKE_DB_PASSWORD') ?: '';
+$db = getenv('WP_SMOKE_DB_NAME') ?: 'wordpress_smoke';
+if (!preg_match('/^[A-Za-z0-9_]+$/', $db)) {
+ fwrite(STDERR, "Unsafe database name.\n");
+ exit(1);
+}
+$mysqli = mysqli_init();
+if (!$mysqli || !$mysqli->real_connect($host, $user, $pass, null, $port)) {
+ fwrite(STDERR, "Unable to connect to MySQL: " . mysqli_connect_error() . "\n");
+ exit(1);
+}
+$mysqli->query('DROP DATABASE IF EXISTS ' . $db);
+if (!$mysqli->query('CREATE DATABASE ' . $db . ' CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci')) {
+ fwrite(STDERR, "Unable to create database: " . $mysqli->error . "\n");
+ exit(1);
+}
+`;
+
+ run('Create WordPress smoke database', 'php', ['-r', code], { env });
+}
+
+function dropDatabase(env) {
+ if (!databaseName) {
+ return;
+ }
+
+ const code = String.raw`
+$host = getenv('WP_SMOKE_DB_HOST') ?: '127.0.0.1';
+$port = (int) (getenv('WP_SMOKE_DB_PORT') ?: 3306);
+$user = getenv('WP_SMOKE_DB_USER') ?: 'root';
+$pass = getenv('WP_SMOKE_DB_PASSWORD') ?: '';
+$db = getenv('WP_SMOKE_DB_NAME') ?: '';
+if (!preg_match('/^[A-Za-z0-9_]+$/', $db)) {
+ exit(0);
+}
+$mysqli = mysqli_init();
+if ($mysqli && $mysqli->real_connect($host, $user, $pass, null, $port)) {
+ $mysqli->query('DROP DATABASE IF EXISTS ' . $db);
+}
+`;
+
+ try {
+ run('Drop WordPress smoke database', 'php', ['-r', code], { env });
+ }
+ catch (error) {
+ appendLog('commands.log', `Database cleanup failed: ${error.message}\n`);
+ }
+}
+
+function stopServer() {
+ if (!server) {
+ return;
+ }
+
+ server.kill();
+ server = null;
+}
+
+function copyDirectory(source, destination, filter) {
+ fs.cpSync(source, destination, {
+ recursive: true,
+ filter: (sourcePath) => filter(path.relative(source, sourcePath).split(path.sep)),
+ });
+}
+
+function copyThemes(themesDir) {
+ const parentTheme = path.join(themesDir, 'emulsify');
+ const childTheme = path.join(themesDir, 'whisk');
+
+ copyDirectory(repoRoot, parentTheme, (segments) => {
+ const first = segments[0];
+ const second = segments[1];
+
+ if ([
+ '.git',
+ '.github',
+ '.coverage',
+ '.out',
+ '.publish',
+ 'node_modules',
+ 'vendor',
+ ].includes(first)) {
+ return false;
+ }
+
+ if ('whisk' === first && ['.coverage', '.out', 'node_modules'].includes(second)) {
+ return false;
+ }
+
+ return true;
+ });
+
+ copyDirectory(path.join(repoRoot, 'whisk'), childTheme, (segments) => {
+ const first = segments[0];
+
+ return ![
+ '.coverage',
+ '.out',
+ 'node_modules',
+ ].includes(first);
+ });
+
+ return { childTheme, parentTheme };
+}
+
+function installThemeDependencies(parentTheme) {
+ run('Install Timber with Composer', 'composer', ['install', '--no-interaction', '--no-progress', '--prefer-dist', '--no-dev'], {
+ cwd: parentTheme,
+ });
+}
+
+function assertGeneratedChildTheme(themePath, slug) {
+ const style = fs.readFileSync(path.join(themePath, 'style.css'), 'utf8');
+ const project = JSON.parse(fs.readFileSync(path.join(themePath, 'project.emulsify.json'), 'utf8'));
+ const packageJson = JSON.parse(fs.readFileSync(path.join(themePath, 'package.json'), 'utf8'));
+ const requiredFiles = [
+ 'assets/icons/.gitkeep',
+ 'assets/images/.gitkeep',
+ 'config/acf-json/.gitkeep',
+ 'patterns/.gitkeep',
+ 'src/components/.gitkeep',
+ 'project.emulsify.json',
+ 'templates/page.twig',
+ ];
+
+ if (!style.includes('Theme Name: Smoke Generated') || !style.includes('Template: emulsify')) {
+ throw new Error('Generated child theme style.css headers were not updated correctly.');
+ }
+
+ if (packageJson.name !== slug) {
+ throw new Error(`Generated child theme package.json name should be ${slug}.`);
+ }
+
+ if (
+ project.project?.platform !== 'wordpress' ||
+ project.project?.name !== 'Smoke Generated' ||
+ project.project?.machineName !== slug ||
+ project.project?.generatedFrom !== 'emulsify-wordpress' ||
+ project.project?.generatedFromVersion !== '2.0.0'
+ ) {
+ throw new Error('Generated child theme project.emulsify.json metadata is incorrect.');
+ }
+
+ for (const file of requiredFiles) {
+ const filePath = path.join(themePath, file);
+ if (!fs.existsSync(filePath)) {
+ throw new Error(`Generated child theme is missing expected Whisk file: ${file}`);
+ }
+ }
+
+ for (const file of ['src/foundation.scss', 'src/layout.scss', 'src/tokens.scss']) {
+ if (fs.existsSync(path.join(themePath, file))) {
+ throw new Error(`Generated child theme should not include assumed Sass entrypoint: ${file}`);
+ }
+ }
+
+ if (fs.existsSync(path.join(themePath, 'theme.json'))) {
+ throw new Error('Generated child theme should not include an empty child theme.json by default.');
+ }
+
+ for (const directory of ['src/editor', 'src/foundation', 'src/layout']) {
+ if (fs.existsSync(path.join(themePath, directory))) {
+ throw new Error(`Generated child theme should not include assumed source directory: ${directory}`);
+ }
+ }
+
+ for (const directory of ['dist', '.out']) {
+ if (fs.existsSync(path.join(themePath, directory))) {
+ throw new Error(`Generated child theme should not copy ignored build output directory: ${directory}`);
+ }
+ }
+}
+
+function installGeneratedChildFixtures(themePath) {
+ const globalDirectory = path.join(themePath, 'dist', 'global');
+ const acfDirectory = path.join(themePath, 'dist', 'components', 'smoke-acf');
+
+ fs.mkdirSync(globalDirectory, { recursive: true });
+ fs.mkdirSync(path.join(acfDirectory, 'css'), { recursive: true });
+ fs.writeFileSync(
+ path.join(globalDirectory, 'smoke.css'),
+ '.smoke-generated-global { color: inherit; }\n'
+ );
+ fs.writeFileSync(
+ path.join(acfDirectory, 'css', 'smoke-acf.css'),
+ '.smoke-acf { display: block; }\n'
+ );
+ fs.writeFileSync(
+ path.join(acfDirectory, 'smoke-acf.twig'),
+ '{{ title|default("Smoke ACF") }} \n'
+ );
+ fs.writeFileSync(
+ path.join(acfDirectory, 'smoke-acf.component.json'),
+ JSON.stringify(
+ {
+ name: 'emulsify-smoke-acf',
+ title: 'Smoke ACF',
+ description: 'Fixture component for WordPress smoke coverage.',
+ category: 'widgets',
+ icon: 'admin-generic',
+ keywords: ['smoke'],
+ },
+ null,
+ 2
+ ) + '\n'
+ );
+}
+
+function installNativeBlockFixture(themePath) {
+ const blockDirectory = path.join(themePath, 'dist', 'components', 'smoke-native');
+ const metadata = {
+ apiVersion: 3,
+ name: 'emulsify/smoke-native',
+ title: 'Smoke Native',
+ category: 'widgets',
+ textdomain: 'smoke-generated',
+ };
+
+ fs.mkdirSync(blockDirectory, { recursive: true });
+ fs.writeFileSync(
+ path.join(blockDirectory, 'block.json'),
+ JSON.stringify(metadata, null, 2) + '\n'
+ );
+}
+
+function installAcfStub(wpPath) {
+ const muPlugins = path.join(wpPath, 'wp-content', 'mu-plugins');
+ fs.mkdirSync(muPlugins, { recursive: true });
+ fs.writeFileSync(
+ path.join(muPlugins, 'emulsify-acf-smoke.php'),
+ `acf_components();
+$fixture = null;
+
+foreach ( $components as $component ) {
+ if ( isset( $component['relative'], $component['source'] ) && 'smoke-acf' === $component['relative'] && 'child' === $component['source'] ) {
+ $fixture = $component;
+ break;
+ }
+}
+
+if ( null === $fixture ) {
+ emulsify_smoke_fail( 'Generated child ACF/Twig fixture component was not discovered without ACF.' );
+}
+
+if ( 'dist/components/smoke-acf/smoke-acf.twig' !== $fixture['template'] ) {
+ emulsify_smoke_fail( 'Generated child ACF/Twig fixture template was not resolved.' );
+}
+
+( new \Emulsify\Theme\Blocks\AcfBlocks( $locator ) )->register_blocks();
+`;
+
+ wp(wpPath, ['eval', code]);
+}
+
+function runAcfDiscoveryWithStub(wpPath) {
+ const code = String.raw`
+function emulsify_smoke_fail( $message ) {
+ fwrite( STDERR, $message . "\n" );
+ exit( 1 );
+}
+
+if ( ! function_exists( 'acf_register_block_type' ) ) {
+ emulsify_smoke_fail( 'ACF block API stub is not loaded.' );
+}
+
+$GLOBALS['emulsify_smoke_acf_registered'] = array();
+do_action( 'acf/init' );
+$registered = isset( $GLOBALS['emulsify_smoke_acf_registered'] ) && is_array( $GLOBALS['emulsify_smoke_acf_registered'] )
+ ? $GLOBALS['emulsify_smoke_acf_registered']
+ : array();
+$fixture = null;
+
+foreach ( $registered as $block ) {
+ if ( isset( $block['name'] ) && 'emulsify-smoke-acf' === $block['name'] ) {
+ $fixture = $block;
+ break;
+ }
+}
+
+if ( null === $fixture ) {
+ emulsify_smoke_fail( 'Generated child ACF/Twig fixture block was not registered with the ACF stub.' );
+}
+
+if ( 'dist/components/smoke-acf/smoke-acf.twig' !== $fixture['twig_template'] ) {
+ emulsify_smoke_fail( 'Generated child ACF/Twig fixture did not keep its Twig template.' );
+}
+
+if ( empty( $fixture['data']['twig_template'] ) || 'dist/components/smoke-acf/smoke-acf.twig' !== $fixture['data']['twig_template'] ) {
+ emulsify_smoke_fail( 'Generated child ACF/Twig fixture data did not include its Twig template.' );
+}
+`;
+
+ wp(wpPath, ['eval', code]);
+}
+
+function runNativeBlockDiscovery(wpPath) {
+ const code = String.raw`
+function emulsify_smoke_fail( $message ) {
+ fwrite( STDERR, $message . "\n" );
+ exit( 1 );
+}
+
+$registry = \WP_Block_Type_Registry::get_instance();
+
+if ( ! $registry->is_registered( 'emulsify/smoke-native' ) ) {
+ emulsify_smoke_fail( 'Generated child native block.json fixture was not registered.' );
+}
+
+$locator = new \Emulsify\Theme\Blocks\ComponentLocator();
+$native = $locator->native_block_directories();
+$found = false;
+
+foreach ( $native as $block ) {
+ if (
+ isset( $block['name'], $block['relative'], $block['source'] ) &&
+ 'emulsify/smoke-native' === $block['name'] &&
+ 'smoke-native' === $block['relative'] &&
+ 'child' === $block['source']
+ ) {
+ $found = true;
+ break;
+ }
+}
+
+if ( ! $found ) {
+ emulsify_smoke_fail( 'Generated child native block.json fixture was not discovered child-first.' );
+}
+`;
+
+ wp(wpPath, ['eval', code]);
+}
+
+function configureDebugLog(wpPath) {
+ const configPath = path.join(wpPath, 'wp-config.php');
+ const config = fs.readFileSync(configPath, 'utf8');
+ const debugConfig = `
+define( 'WP_DEBUG', true );
+define( 'WP_DEBUG_LOG', true );
+define( 'WP_DEBUG_DISPLAY', false );
+define( 'WP_ENVIRONMENT_TYPE', 'local' );
+@ini_set( 'display_errors', 0 );
+`;
+
+ fs.writeFileSync(
+ configPath,
+ config.replace(/\/\* That's all, stop editing!.*$/s, `${debugConfig}\n$&`)
+ );
+}
+
+function createFixtureContent(wpPath) {
+ const pageId = wp(wpPath, [
+ 'post',
+ 'create',
+ '--post_type=page',
+ '--post_status=publish',
+ '--post_title=Smoke Page',
+ '--post_name=smoke-page',
+ '--post_content=Smoke page content',
+ '--porcelain',
+ ]);
+ const postId = wp(wpPath, [
+ 'post',
+ 'create',
+ '--post_type=post',
+ '--post_status=publish',
+ '--post_title=Smoke Post',
+ '--post_name=smoke-post',
+ '--post_content=Smoke post content',
+ '--porcelain',
+ ]);
+ const authorId = wp(wpPath, [
+ 'user',
+ 'create',
+ 'smokeauthor',
+ 'smokeauthor@example.test',
+ '--role=author',
+ '--display_name=Smoke Author',
+ '--porcelain',
+ ]);
+ const categoryId = wp(wpPath, [
+ 'term',
+ 'create',
+ 'category',
+ 'Smoke Category',
+ '--slug=smoke-category',
+ '--porcelain',
+ ]);
+
+ wp(wpPath, ['post', 'update', postId, `--post_author=${authorId}`]);
+ wp(wpPath, ['post', 'term', 'add', postId, 'category', categoryId]);
+ wp(wpPath, [
+ 'comment',
+ 'create',
+ `--comment_post_ID=${postId}`,
+ '--comment_content=Smoke comment content',
+ '--comment_author=Smoke Commenter',
+ '--comment_author_email=commenter@example.test',
+ '--comment_approved=1',
+ ]);
+
+ return { authorId, categoryId, pageId, postId };
+}
+
+function getAvailablePort() {
+ return new Promise((resolve, reject) => {
+ const probe = net.createServer();
+
+ probe.once('error', reject);
+ probe.listen(0, host, () => {
+ const address = probe.address();
+ probe.close(() => resolve(address.port));
+ });
+ });
+}
+
+function startServer(wpPath, port) {
+ server = childProcess.spawn('php', ['-S', `${host}:${port}`, '-t', wpPath], {
+ cwd: wpPath,
+ stdio: ['ignore', 'pipe', 'pipe'],
+ });
+
+ server.stdout.on('data', (data) => appendLog('server.log', data.toString()));
+ server.stderr.on('data', (data) => appendLog('server.log', data.toString()));
+ server.on('exit', (code, signal) => {
+ appendLog('server.log', `\nPHP server exited with code ${code} signal ${signal}\n`);
+ });
+}
+
+async function waitForServer(baseUrl) {
+ const deadline = Date.now() + 30000;
+ let lastError = null;
+
+ while (Date.now() < deadline) {
+ try {
+ const response = await fetch(baseUrl);
+ if (response.status < 500) {
+ return;
+ }
+ }
+ catch (error) {
+ lastError = error;
+ }
+
+ await new Promise((resolve) => setTimeout(resolve, 500));
+ }
+
+ throw new Error(`WordPress fixture server did not become ready. ${lastError ? lastError.message : ''}`);
+}
+
+async function checkRoute(baseUrl, route) {
+ const response = await fetch(`${baseUrl}${route.path}`);
+ const body = await response.text();
+ const expectedStatus = route.status || 200;
+
+ appendLog('http.log', `\n${route.name} ${route.path}\nStatus: ${response.status}\n`);
+ appendLog('http.log', body.slice(0, 4000));
+ appendLog('http.log', '\n');
+
+ if (response.status !== expectedStatus) {
+ throw new Error(`${route.name} returned ${response.status}; expected ${expectedStatus}.`);
+ }
+
+ for (const text of route.includes) {
+ if (!body.includes(text)) {
+ throw new Error(`${route.name} did not include expected text: ${text}`);
+ }
+ }
+
+ return body;
+}
+
+async function checkGeneratedAsset(baseUrl, themeSlug, relativePath, expectedText) {
+ const response = await fetch(`${baseUrl}/wp-content/themes/${themeSlug}/${relativePath}`);
+ const body = await response.text();
+
+ appendLog('http.log', `\nasset ${relativePath}\nStatus: ${response.status}\n`);
+ appendLog('http.log', body.slice(0, 1000));
+ appendLog('http.log', '\n');
+
+ if (response.status !== 200) {
+ throw new Error(`Generated child asset ${relativePath} returned ${response.status}; expected 200.`);
+ }
+
+ if (expectedText && !body.includes(expectedText)) {
+ throw new Error(`Generated child asset ${relativePath} did not include expected text: ${expectedText}`);
+ }
+}
+
+async function checkGeneratedAssets(baseUrl, themeSlug) {
+ await checkGeneratedAsset(baseUrl, themeSlug, 'dist/global/smoke.css', '.smoke-generated-global');
+ await checkGeneratedAsset(baseUrl, themeSlug, 'dist/components/smoke-acf/css/smoke-acf.css', '.smoke-acf');
+}
+
+async function renderRoutes(wpPath, content, baseUrl, port, themeSlug) {
+ startServer(wpPath, port);
+ await waitForServer(baseUrl);
+
+ const pageIncludes = [
+ 'Smoke Page',
+ 'Smoke page content',
+ `${themeSlug}-page`,
+ `/wp-content/themes/${themeSlug}/dist/global/smoke.css`,
+ `/wp-content/themes/${themeSlug}/dist/components/smoke-acf/css/smoke-acf.css`,
+ ];
+ const routes = [
+ { name: 'home', path: '/', includes: ['Emulsify Smoke', 'Smoke Post'] },
+ { name: 'page', path: `/?page_id=${content.pageId}`, includes: pageIncludes },
+ { name: 'single', path: `/?p=${content.postId}`, includes: ['Smoke Post', 'Smoke post content', 'Smoke comment content'] },
+ { name: 'archive', path: `/?cat=${content.categoryId}`, includes: ['Smoke Category', 'Smoke Post'] },
+ { name: 'search', path: '/?s=Smoke', includes: ['Search results for Smoke', 'Smoke Post'] },
+ { name: 'author', path: `/?author=${content.authorId}`, includes: ['Archive of Smoke Author', 'Smoke Post'] },
+ { name: '404', path: '/?p=9999999', status: 404, includes: ['Page not found'] },
+ ];
+
+ for (const route of routes) {
+ await checkRoute(baseUrl, route);
+ }
+
+ await checkGeneratedAssets(baseUrl, themeSlug);
+}
+
+function dumpLogs(wpPath) {
+ if (!logDir) {
+ return;
+ }
+
+ const files = [
+ path.join(logDir, 'commands.log'),
+ path.join(logDir, 'server.log'),
+ path.join(logDir, 'http.log'),
+ path.join(wpPath || '', 'wp-content', 'debug.log'),
+ ];
+
+ console.error('\nWordPress Smoke Logs');
+ for (const file of files) {
+ if (!file || !fs.existsSync(file)) {
+ continue;
+ }
+
+ const contents = fs.readFileSync(file, 'utf8');
+ const tail = contents.slice(-12000);
+ console.error(`\n--- ${file} ---\n${tail}`);
+ }
+}
+
+async function main() {
+ if (process.env.WP_SMOKE_SKIP === '1') {
+ unavailable('WP_SMOKE_SKIP=1');
+ }
+
+ ensureTools();
+
+ workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'emulsify-wordpress-smoke-'));
+ logDir = path.join(workDir, 'logs');
+ fs.mkdirSync(logDir, { recursive: true });
+
+ const wpPath = path.join(workDir, 'wordpress');
+ const env = databaseEnv();
+ currentDatabaseEnv = env;
+ const port = Number(process.env.WP_SMOKE_PORT) || await getAvailablePort();
+ const baseUrl = `http://${host}:${port}`;
+
+ log(`Fixture directory: ${workDir}`);
+ createDatabase(env);
+
+ run('Download WordPress core', 'wp', [
+ '--allow-root',
+ `--path=${wpPath}`,
+ 'core',
+ 'download',
+ `--version=${wordpressVersion}`,
+ '--force',
+ ]);
+
+ run('Create wp-config.php', 'wp', [
+ '--allow-root',
+ `--path=${wpPath}`,
+ 'config',
+ 'create',
+ `--dbname=${env.WP_SMOKE_DB_NAME}`,
+ `--dbuser=${env.WP_SMOKE_DB_USER}`,
+ `--dbpass=${env.WP_SMOKE_DB_PASSWORD}`,
+ `--dbhost=${env.WP_SMOKE_DB_HOST}:${env.WP_SMOKE_DB_PORT}`,
+ '--skip-check',
+ ]);
+ configureDebugLog(wpPath);
+
+ const themesDir = path.join(wpPath, 'wp-content', 'themes');
+ const { parentTheme } = copyThemes(themesDir);
+ installThemeDependencies(parentTheme);
+ const generatedChildSlug = 'smoke-generated';
+ const generatedChild = path.join(themesDir, generatedChildSlug);
+
+ wp(wpPath, [
+ 'core',
+ 'install',
+ `--url=${baseUrl}`,
+ '--title=Emulsify Smoke',
+ '--admin_user=admin',
+ '--admin_password=admin-password-123',
+ '--admin_email=admin@example.test',
+ ]);
+ wp(wpPath, ['theme', 'is-installed', 'emulsify']);
+ wp(wpPath, ['theme', 'is-installed', 'whisk']);
+ wp(wpPath, ['theme', 'activate', 'whisk']);
+ wp(wpPath, ['emulsify', 'Smoke Generated', `--machine-name=${generatedChildSlug}`, '--activate']);
+ assertGeneratedChildTheme(generatedChild, generatedChildSlug);
+ installGeneratedChildFixtures(generatedChild);
+ wp(wpPath, ['theme', 'is-installed', generatedChildSlug]);
+
+ const activeStylesheet = wp(wpPath, ['option', 'get', 'stylesheet']);
+ if (activeStylesheet !== generatedChildSlug) {
+ throw new Error(`Generated child theme was not activated. Active stylesheet is "${activeStylesheet}".`);
+ }
+
+ installNativeBlockFixture(generatedChild);
+ runAcfDiscoveryWithoutAcf(wpPath);
+ runNativeBlockDiscovery(wpPath);
+ installAcfStub(wpPath);
+ runAcfDiscoveryWithStub(wpPath);
+ wp(wpPath, [
+ 'eval',
+ 'if ( ! class_exists( "\\\\Timber\\\\Timber" ) ) { fwrite( STDERR, "Timber is not loaded.\\n" ); exit( 1 ); }',
+ ]);
+
+ const content = createFixtureContent(wpPath);
+ await renderRoutes(wpPath, content, baseUrl, port, generatedChildSlug);
+ stopServer();
+
+ log('Generated and activated a child theme, checked block discovery, loaded assets, and rendered home, page, single, archive, search, author, and 404 routes.');
+ dropDatabase(env);
+
+ if (!keepFixture) {
+ fs.rmSync(workDir, { force: true, recursive: true });
+ }
+ else {
+ log(`Keeping fixture directory: ${workDir}`);
+ }
+}
+
+main()
+ .catch((error) => {
+ console.error(`WordPress fixture smoke failed: ${error.message}`);
+ dumpLogs(workDir ? path.join(workDir, 'wordpress') : null);
+
+ if (server) {
+ stopServer();
+ }
+
+ if (currentDatabaseEnv) {
+ dropDatabase(currentDatabaseEnv);
+ }
+
+ if (workDir && !keepFixture) {
+ fs.rmSync(workDir, { force: true, recursive: true });
+ }
+
+ process.exit(1);
+ })
+ .finally(() => {
+ if (server) {
+ stopServer();
+ }
+ });
diff --git a/.github/scripts/wordpress-starter-init-smoke.cjs b/.github/scripts/wordpress-starter-init-smoke.cjs
new file mode 100644
index 0000000..27a8e8c
--- /dev/null
+++ b/.github/scripts/wordpress-starter-init-smoke.cjs
@@ -0,0 +1,207 @@
+#!/usr/bin/env node
+
+// Smoke checks for the standalone WordPress starter path used by emulsify-cli.
+// The fixture mirrors the current CLI sequence: clone starter, write
+// project.emulsify.json, npm install creates a lockfile, then .cli/init.js runs.
+
+const childProcess = require('child_process');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+
+const repoRoot = path.resolve(__dirname, '../..');
+const starterRoot = path.join(repoRoot, 'whisk');
+const excludedCopySegments = new Set([
+ '.coverage',
+ '.git',
+ '.out',
+ '.cache',
+ 'dist',
+ 'node_modules',
+]);
+
+function assert(condition, message) {
+ if (!condition) {
+ throw new Error(message);
+ }
+}
+
+function readJson(filePath) {
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
+}
+
+function writeJson(filePath, data) {
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
+ fs.writeFileSync(filePath, `${JSON.stringify(data, null, 2)}\n`);
+}
+
+function copyStarterFixture(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()) {
+ copyStarterFixture(sourcePath, destinationPath);
+ continue;
+ }
+
+ if (entry.isFile()) {
+ fs.mkdirSync(path.dirname(destinationPath), { recursive: true });
+ fs.copyFileSync(sourcePath, destinationPath);
+ }
+ }
+}
+
+function removePath(filePath) {
+ if (fs.existsSync(filePath)) {
+ fs.rmSync(filePath, { force: true, recursive: true });
+ }
+}
+
+function parseThemeHeader(contents) {
+ return Object.fromEntries(
+ contents
+ .split(/\r?\n/)
+ .map((line) => line.match(/^\s*\*\s*([^:]+):\s*(.*?)\s*$/))
+ .filter(Boolean)
+ .map((match) => [match[1].trim(), match[2].trim()]),
+ );
+}
+
+function runNodeScript(scriptPath) {
+ const result = childProcess.spawnSync(process.execPath, [scriptPath], {
+ cwd: path.dirname(scriptPath),
+ encoding: 'utf8',
+ env: process.env,
+ });
+
+ if (result.status !== 0) {
+ throw new Error(
+ `${scriptPath} failed:\n${result.stdout || ''}${result.stderr || ''}`.trim(),
+ );
+ }
+}
+
+function runWhiskTestScript() {
+ const jestBin = path.join(
+ starterRoot,
+ 'node_modules',
+ '.bin',
+ process.platform === 'win32' ? 'jest.cmd' : 'jest',
+ );
+
+ assert(
+ fs.existsSync(jestBin),
+ 'Whisk dependencies are not installed. Run `npm run whisk:install` before the starter init smoke test.',
+ );
+
+ const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm';
+ const result = childProcess.spawnSync(npm, ['--prefix', 'whisk', 'run', 'test'], {
+ cwd: repoRoot,
+ encoding: 'utf8',
+ env: process.env,
+ });
+
+ if (result.status !== 0) {
+ throw new Error(
+ `npm --prefix whisk run test failed:\n${result.stdout || ''}${result.stderr || ''}`.trim(),
+ );
+ }
+}
+
+const workRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'emulsify-wordpress-starter-'));
+const target = path.join(workRoot, 'acme-theme');
+
+try {
+ copyStarterFixture(starterRoot, target);
+
+ writeJson(path.join(target, 'project.emulsify.json'), {
+ project: {
+ platform: 'wordpress',
+ name: 'Acme Theme',
+ machineName: 'acme-theme',
+ },
+ starter: {
+ repository: 'https://github.com/emulsify-ds/emulsify-wordpress-starter',
+ },
+ });
+
+ writeJson(path.join(target, 'patterns/smoke-pattern.json'), {
+ name: 'whisk/smoke-pattern',
+ title: 'Smoke Pattern',
+ categories: ['text'],
+ content: 'Smoke pattern content
',
+ });
+
+ writeJson(path.join(target, 'package-lock.json'), {
+ name: 'whisk',
+ version: '2.0.0',
+ lockfileVersion: 3,
+ requires: true,
+ packages: {
+ '': {
+ name: 'whisk',
+ version: '2.0.0',
+ dependencies: {
+ '@emulsify/core': '^4.1.0',
+ },
+ },
+ },
+ });
+
+ runNodeScript(path.join(target, '.cli/init.js'));
+
+ const style = parseThemeHeader(fs.readFileSync(path.join(target, 'style.css'), 'utf8'));
+ const packageJson = readJson(path.join(target, 'package.json'));
+ const packageLock = readJson(path.join(target, 'package-lock.json'));
+ const project = readJson(path.join(target, 'project.emulsify.json'));
+ const pattern = readJson(path.join(target, 'patterns/smoke-pattern.json'));
+ const functionsPhp = fs.readFileSync(path.join(target, 'functions.php'), 'utf8');
+ const pageTwig = fs.readFileSync(path.join(target, 'templates/page.twig'), 'utf8');
+
+ assert(style['Theme Name'] === 'Acme Theme', 'style.css should update Theme Name.');
+ assert(style['Text Domain'] === 'acme-theme', 'style.css should update Text Domain.');
+ assert(style.Template === 'emulsify', 'style.css should keep Template: emulsify.');
+ assert(packageJson.name === 'acme-theme', 'package.json should update name.');
+ assert(packageLock.name === 'acme-theme', 'package-lock.json should update the root name.');
+ assert(packageLock.packages[''].name === 'acme-theme', 'package-lock.json packages[""].name should update.');
+ assert(project.project.platform === 'wordpress', 'project.emulsify.json should keep project.platform: wordpress.');
+ assert(project.project.name === 'Acme Theme', 'project.emulsify.json should update project.name.');
+ assert(project.project.machineName === 'acme-theme', 'project.emulsify.json should update project.machineName.');
+ assert(project.project.generatedFrom === 'emulsify-wordpress', 'project.emulsify.json should identify the generated child theme source.');
+ assert(project.project.generatedFromVersion === '2.0.0', 'project.emulsify.json should record the generated child theme source version.');
+ assert(
+ project.starter.repository === 'https://github.com/emulsify-ds/emulsify-wordpress-starter',
+ 'project.emulsify.json should keep the standalone starter repository.',
+ );
+ assert(functionsPhp.includes('Acme Theme child theme hooks.'), 'functions.php should update visible Whisk labels.');
+ assert(pageTwig.includes('acme-theme-page'), 'templates/page.twig should update the page class.');
+ assert(!pageTwig.includes('whisk-page'), 'templates/page.twig should not keep the starter page class.');
+ assert(pattern.name === 'acme-theme/smoke-pattern', 'JSON pattern names should update the whisk namespace.');
+ assert(fs.existsSync(path.join(target, '.cli/init.js')), 'Starter should include the emulsify-cli init hook.');
+ assert(fs.existsSync(path.join(target, '.gitignore')), 'Starter should include standalone ignore rules.');
+ assert(fs.existsSync(path.join(target, '.nvmrc')), 'Starter should use .nvmrc for Node tooling.');
+ assert(!fs.existsSync(path.join(target, '.nvm')), 'Starter should not keep the legacy .nvm file.');
+
+ for (const generatedPath of ['node_modules', 'dist', '.out', '.coverage', '.cache', '.git']) {
+ assert(
+ !fs.existsSync(path.join(target, generatedPath)),
+ `Cloned starter fixture should not contain copied ${generatedPath} output.`,
+ );
+ }
+
+ runWhiskTestScript();
+
+ console.log('WordPress starter init smoke checks passed.');
+} catch (error) {
+ console.error(error.message);
+ process.exitCode = 1;
+} finally {
+ removePath(workRoot);
+}
diff --git a/.github/stale.yml b/.github/stale.yml
new file mode 100644
index 0000000..a620800
--- /dev/null
+++ b/.github/stale.yml
@@ -0,0 +1,16 @@
+# Number of days of inactivity before an issue becomes stale
+daysUntilStale: 60
+# Number of days of inactivity before a stale issue is closed
+daysUntilClose: 7
+# Issues with these labels will never be considered stale
+exemptLabels:
+ - 'Priority: Critical'
+# Label to use when marking an issue as stale
+staleLabel: 'Automatically Closed'
+# Comment to post when marking an issue as stale. Set to `false` to disable
+markComment: >
+ This issue has been automatically marked as stale because it has not had
+ recent activity. It will be closed if no further activity occurs. Thank you
+ for your contributions.
+# Comment to post when closing a stale issue. Set to `false` to disable
+closeComment: false
diff --git a/.github/workflows/addtoprojects.yml b/.github/workflows/addtoprojects.yml
index 7bebb51..9fb9df2 100644
--- a/.github/workflows/addtoprojects.yml
+++ b/.github/workflows/addtoprojects.yml
@@ -7,6 +7,7 @@ on:
pull_request:
types:
- opened
+ workflow_dispatch:
jobs:
add-to-project:
@@ -18,4 +19,4 @@ jobs:
# You can target a repository in a different organization
# to the issue
project-url: https://github.com/orgs/emulsify-ds/projects/6
- github-token: ${{ secrets.ADD_TO_PROJECT_PAT }}
+ token: ${{ secrets.ADD_TO_PROJECT_PAT }}
diff --git a/.github/workflows/contributors.yml b/.github/workflows/contributors.yml
index c74d157..07b7454 100644
--- a/.github/workflows/contributors.yml
+++ b/.github/workflows/contributors.yml
@@ -1,10 +1,11 @@
name: Add contributors
on:
schedule:
- - cron: "20 20 * * *"
+ - cron: '20 20 * * *'
push:
branches:
- - master
+ - main
+ workflow_dispatch:
jobs:
add-contributors:
@@ -13,11 +14,11 @@ jobs:
- uses: actions/checkout@v2
- uses: BobAnkh/add-contributors@master
with:
- CONTRIBUTOR: "### Contributors"
- COLUMN_PER_ROW: "6"
+ CONTRIBUTOR: '### Contributors'
+ COLUMN_PER_ROW: '6'
ACCESS_TOKEN: ${{secrets.ADD_TO_PROJECT_PAT}}
- IMG_WIDTH: "100"
- FONT_SIZE: "14"
- PATH: "/README.md"
- COMMIT_MESSAGE: "docs(README): update contributors"
- AVATAR_SHAPE: "round"
+ IMG_WIDTH: '100'
+ FONT_SIZE: '14'
+ PATH: '/README.md'
+ COMMIT_MESSAGE: 'docs(README): update contributors'
+ AVATAR_SHAPE: 'round'
diff --git a/.github/workflows/semantic-release.yml b/.github/workflows/semantic-release.yml
new file mode 100644
index 0000000..8675e56
--- /dev/null
+++ b/.github/workflows/semantic-release.yml
@@ -0,0 +1,124 @@
+name: WordPress Release
+
+# Release publishing is gated by the same local readiness script used by
+# maintainers, with the WordPress fixture required instead of optional. The
+# release-readiness job installs WP-CLI, starts MySQL, and runs release:check
+# with WP_SMOKE_REQUIRED=1 before semantic-release can publish.
+on:
+ push:
+ branches:
+ - main
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: release-${{ github.ref }}
+ cancel-in-progress: false
+
+jobs:
+ release-readiness:
+ name: Release readiness
+ runs-on: ubuntu-latest
+ services:
+ mysql:
+ image: mysql:8.0
+ env:
+ MYSQL_ROOT_PASSWORD: root
+ MYSQL_DATABASE: wordpress_smoke
+ ports:
+ - 3306:3306
+ options: >-
+ --health-cmd="mysqladmin ping -h 127.0.0.1 -uroot -proot"
+ --health-interval=10s
+ --health-timeout=5s
+ --health-retries=10
+ permissions:
+ contents: write
+ issues: write
+ pull-requests: write
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v7
+ with:
+ fetch-depth: 0
+
+ - name: Setup Node
+ uses: actions/setup-node@v6
+ with:
+ node-version-file: .nvmrc
+ cache: npm
+
+ - name: Setup PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: '8.3'
+ extensions: mysqli
+ tools: composer:v2, wp-cli
+ coverage: none
+
+ - name: Clean npm install
+ run: npm ci --ignore-scripts
+
+ - name: Validate Composer metadata
+ run: composer validate --no-check-publish --strict
+
+ - name: Install Composer dependencies for PHP lint
+ run: composer install --no-interaction --no-progress --prefer-dist
+
+ - name: Run PHP lint
+ run: npm run lint:php
+
+ - name: Run runtime npm audit
+ run: npm audit --omit=dev
+
+ - name: Check release config syntax
+ run: node --check release.config.js
+
+ - name: Run release readiness checks
+ env:
+ WP_SMOKE_REQUIRED: '1'
+ WP_SMOKE_DB_HOST: 127.0.0.1
+ WP_SMOKE_DB_PORT: '3306'
+ WP_SMOKE_DB_NAME: wordpress_smoke
+ WP_SMOKE_DB_USER: root
+ WP_SMOKE_DB_PASSWORD: root
+ run: npm run release:check
+
+ - name: Run release dry run
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: npm run publish-test -- --no-ci
+
+ - name: Run full npm audit
+ run: npm audit
+
+ semantic-release:
+ name: Semantic release
+ needs: release-readiness
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write
+ issues: write
+ pull-requests: write
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v7
+ with:
+ fetch-depth: 0
+
+ - name: Setup Node
+ uses: actions/setup-node@v6
+ with:
+ node-version-file: .nvmrc
+ cache: npm
+
+ - name: Clean npm install
+ run: npm ci --ignore-scripts
+
+ - name: Semantic release
+ id: semantic
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: npm run publish
diff --git a/.github/workflows/theme-readiness.yml b/.github/workflows/theme-readiness.yml
new file mode 100644
index 0000000..508522c
--- /dev/null
+++ b/.github/workflows/theme-readiness.yml
@@ -0,0 +1,158 @@
+name: WordPress Theme Readiness
+
+# Pull requests run practical checks without starting MySQL. Manual and
+# scheduled runs can exercise the full WordPress fixture with WP-CLI and MySQL.
+# For the 2.0 release branch, maintainers should run the manual workflow with
+# wordpress_fixture enabled before merging.
+on:
+ pull_request:
+ branches:
+ - main
+ - develop
+ - release-2.x
+ - emulsify-core-integration
+ workflow_dispatch:
+ inputs:
+ wordpress_fixture:
+ description: Run the WordPress fixture smoke test with WP-CLI and MySQL.
+ required: false
+ type: boolean
+ default: true
+ extended_checks:
+ description: Run the Whisk Storybook build and accessibility audit.
+ required: false
+ type: boolean
+ default: false
+ schedule:
+ - cron: '17 11 * * 1'
+
+permissions:
+ contents: read
+
+concurrency:
+ group: theme-readiness-${{ github.event.pull_request.number || github.ref }}
+ cancel-in-progress: ${{ github.event_name == 'pull_request' }}
+
+jobs:
+ practical:
+ name: Practical theme readiness
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v7
+
+ - name: Setup Node
+ uses: actions/setup-node@v6
+ with:
+ node-version-file: .nvmrc
+ cache: npm
+
+ - name: Setup PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: '8.3'
+ tools: composer:v2
+ coverage: none
+
+ - name: Clean npm install
+ run: npm ci --ignore-scripts
+
+ - name: Validate Composer metadata
+ run: composer validate --no-check-publish --strict
+
+ - name: Install Composer dependencies for PHP lint
+ run: composer install --no-interaction --no-progress --prefer-dist
+
+ - name: Run runtime npm audit
+ run: npm audit --omit=dev
+
+ - name: Run full npm audit
+ run: npm audit
+
+ - name: Run PHP lint
+ run: npm run lint:php
+
+ - name: Run PR validation checks
+ run: npm run pr:check
+
+ - name: Run release readiness checks
+ run: npm run release:check
+
+ wordpress-fixture:
+ name: WordPress fixture smoke
+ if: >-
+ github.event_name != 'pull_request' &&
+ (
+ github.event_name == 'schedule' ||
+ (github.event_name == 'workflow_dispatch' && inputs.wordpress_fixture)
+ )
+ runs-on: ubuntu-latest
+ services:
+ mysql:
+ image: mysql:8.0
+ env:
+ MYSQL_ROOT_PASSWORD: root
+ MYSQL_DATABASE: wordpress_smoke
+ ports:
+ - 3306:3306
+ options: >-
+ --health-cmd="mysqladmin ping -h 127.0.0.1 -uroot -proot"
+ --health-interval=10s
+ --health-timeout=5s
+ --health-retries=10
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v7
+
+ - name: Setup Node
+ uses: actions/setup-node@v6
+ with:
+ node-version-file: .nvmrc
+ cache: npm
+
+ - name: Setup PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: '8.3'
+ extensions: mysqli
+ tools: composer:v2, wp-cli
+ coverage: none
+
+ - name: Clean npm install
+ run: npm ci --ignore-scripts
+
+ - name: Install Composer dependencies
+ run: composer install --no-dev --no-interaction --no-progress --prefer-dist
+
+ - name: Run WordPress fixture smoke
+ env:
+ WP_SMOKE_REQUIRED: '1'
+ WP_SMOKE_DB_HOST: 127.0.0.1
+ WP_SMOKE_DB_PORT: '3306'
+ WP_SMOKE_DB_NAME: wordpress_smoke
+ WP_SMOKE_DB_USER: root
+ WP_SMOKE_DB_PASSWORD: root
+ run: npm run release:check
+
+ extended-whisk:
+ name: Extended Whisk Storybook and a11y
+ if: github.event_name == 'workflow_dispatch' && inputs.extended_checks
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v7
+
+ - name: Setup Node
+ uses: actions/setup-node@v6
+ with:
+ node-version-file: .nvmrc
+ cache: npm
+
+ - name: Clean npm install
+ run: npm ci --ignore-scripts
+
+ - name: Install Whisk dependencies
+ run: npm run whisk:install
+
+ - name: Build Storybook and run a11y
+ run: npm --prefix whisk run a11y
diff --git a/.gitignore b/.gitignore
index d7fd7a1..b117e6c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,27 +1,23 @@
-wp-content
-
# Ignore compiled files.
dist
-# Yarn/NPM
-node_modules/
-yarn.lock
-yarn-error.log
-
-# Storybook
-.out
-
-# Gatsby
-.gatsby-context.js
-public
-.cache/
+# npm
+node_modules
# System
.DS_Store
+.idea
+.vscode
# Git deploy
.publish
# Composer
composer.lock
-/vendor/
+vendor
+
+# Jest
+.coverage
+
+# Storybook
+.out
diff --git a/.husky/pre-commit b/.husky/pre-commit
new file mode 100755
index 0000000..20d0d06
--- /dev/null
+++ b/.husky/pre-commit
@@ -0,0 +1,4 @@
+#!/bin/sh
+. "$(dirname "$0")/_/husky.sh"
+
+npm run lint
diff --git a/.npmrc b/.npmrc
deleted file mode 100644
index 5fca0d5..0000000
--- a/.npmrc
+++ /dev/null
@@ -1 +0,0 @@
-scripts-prepend-node-path=true
diff --git a/.nvmrc b/.nvmrc
index 958b5a3..a45fd52 100644
--- a/.nvmrc
+++ b/.nvmrc
@@ -1 +1 @@
-v14
+24
diff --git a/.prettierignore b/.prettierignore
deleted file mode 100644
index b97d4b2..0000000
--- a/.prettierignore
+++ /dev/null
@@ -1,5 +0,0 @@
-dist
-.out
-.coverage
-node_modules
-*.min.js
\ No newline at end of file
diff --git a/.storybook/_attach.js b/.storybook/_attach.js
deleted file mode 100644
index 9d54bcb..0000000
--- a/.storybook/_attach.js
+++ /dev/null
@@ -1,27 +0,0 @@
-// Simple Attach.behaviors usage for Storybook
-
-window.Attach = { behaviors: {} };
-
-(function(Attach) {
- Attach.throwError = function(error) {
- setTimeout(function() {
- throw error;
- }, 0);
- };
-
- Attach.attachBehaviors = function(context, settings) {
- context = context || document;
- settings = settings;
- const behaviors = Attach.behaviors;
-
- Object.keys(behaviors).forEach(function(i) {
- if (typeof behaviors[i].attach === 'function') {
- try {
- behaviors[i].attach(context, settings);
- } catch (e) {
- Attach.throwError(e);
- }
- }
- });
- };
-})(Attach);
diff --git a/.storybook/emulsifyTheme.js b/.storybook/emulsifyTheme.js
deleted file mode 100644
index f6d12a8..0000000
--- a/.storybook/emulsifyTheme.js
+++ /dev/null
@@ -1,12 +0,0 @@
-// Documentation on theming Storybook: https://storybook.js.org/docs/configurations/theming/
-
-import { create } from '@storybook/theming';
-
-export default create({
- base: 'light',
- // Branding
- brandTitle: 'Emulsify',
- brandUrl: 'https://emulsify.info',
- brandImage:
- 'https://raw.githubusercontent.com/emulsify-ds/emulsify-design-system/master/images/logo.png',
-});
diff --git a/.storybook/main.js b/.storybook/main.js
deleted file mode 100644
index 45ac09f..0000000
--- a/.storybook/main.js
+++ /dev/null
@@ -1,8 +0,0 @@
-module.exports = {
- stories: ['../components/**/*.stories.js'],
- addons: [
- '@storybook/addon-a11y',
- '@storybook/addon-actions/register',
- '@storybook/addon-links/register',
- ],
-};
diff --git a/.storybook/manager.js b/.storybook/manager.js
deleted file mode 100644
index 7010e25..0000000
--- a/.storybook/manager.js
+++ /dev/null
@@ -1,7 +0,0 @@
-import { addons } from '@storybook/addons';
-
-import emulsifyTheme from './emulsifyTheme';
-
-addons.setConfig({
- theme: emulsifyTheme,
-});
diff --git a/.storybook/preview-head.html b/.storybook/preview-head.html
deleted file mode 100644
index ec63390..0000000
--- a/.storybook/preview-head.html
+++ /dev/null
@@ -1,5 +0,0 @@
-
-
diff --git a/.storybook/preview.js b/.storybook/preview.js
deleted file mode 100644
index 2e7e438..0000000
--- a/.storybook/preview.js
+++ /dev/null
@@ -1,18 +0,0 @@
-import { addDecorator } from '@storybook/react';
-import { useEffect } from '@storybook/client-api';
-import Twig from 'twig';
-import { setupTwig } from './setupTwig';
-
-// GLOBAL CSS
-import '../components/style.scss';
-
-// Script to "attach" DOM scripts for Storybook.
-import './_attach.js';
-
-// addDecorator deprecated, but not sure how to use this otherwise.
-addDecorator((storyFn) => {
- useEffect(() => Attach.attachBehaviors(), []);
- return storyFn();
-});
-
-setupTwig(Twig);
diff --git a/.storybook/setupTwig.js b/.storybook/setupTwig.js
deleted file mode 100644
index 1056922..0000000
--- a/.storybook/setupTwig.js
+++ /dev/null
@@ -1,30 +0,0 @@
-const { resolve } = require('path');
-const twigBEM = require('bem-twig-extension');
-const twigAddAttributes = require('add-attributes-twig-extension');
-
-module.exports.namespaces = {
- atoms: resolve(__dirname, '../', 'components/01-atoms'),
- molecules: resolve(__dirname, '../', 'components/02-molecules'),
- organisms: resolve(__dirname, '../', 'components/03-organisms'),
- templates: resolve(__dirname, '../', 'components/04-templates'),
-};
-
-/**
- * Configures and extends a standard twig object.
- *
- * @param {Twig} twig - twig object that should be configured and extended.
- *
- * @returns {Twig} configured twig object.
- */
-module.exports.setupTwig = function setupTwig(twig) {
- twig.cache();
- twig.extendFunction("function", function() {
- return "";
- });
- twig.extendFunction("_e", function() {
- return "";
- });
- twigBEM(twig);
- twigAddAttributes(twig);
- return twig;
-};
diff --git a/.storybook/webpack.config.js b/.storybook/webpack.config.js
deleted file mode 100644
index 7b540b3..0000000
--- a/.storybook/webpack.config.js
+++ /dev/null
@@ -1,76 +0,0 @@
-const path = require('path');
-const globImporter = require('node-sass-glob-importer');
-const _StyleLintPlugin = require('stylelint-webpack-plugin');
-const { namespaces } = require('./setupTwig');
-
-module.exports = async ({ config }) => {
-
- // Twig
- config.module.rules.push({
- test: /\.twig$/,
- use: [
- {
- loader: 'twig-loader',
- options: {
- twigOptions: {
- namespaces,
- },
- },
- },
- ],
- });
-
- // SCSS
- config.module.rules.push({
- test: /\.s[ac]ss$/i,
- use: [
- 'style-loader',
- {
- loader: 'css-loader',
- options: {
- sourceMap: true,
- },
- },
- {
- loader: 'sass-loader',
- options: {
- sourceMap: true,
- sassOptions: {
- importer: globImporter(),
- },
- },
- },
- ],
- });
-
- config.plugins.push(
- new _StyleLintPlugin({
- configFile: path.resolve(__dirname, '../', 'webpack/.stylelintrc'),
- context: path.resolve(__dirname, '../', 'components'),
- files: '**/*.scss',
- failOnError: false,
- quiet: false,
- }),
- );
-
- // YAML
- config.module.rules.push({
- test: /\.ya?ml$/,
- loader: 'js-yaml-loader',
- });
-
- // JS
- config.module.rules.push({
- test: /\.js$/,
- exclude: /node_modules/,
- loader: 'eslint-loader',
- options: {
- cache: true,
- global: [
- 'Attach'
- ]
- },
- });
-
- return config;
-};
diff --git a/.stylelintrc b/.stylelintrc
deleted file mode 100644
index 948644b..0000000
--- a/.stylelintrc
+++ /dev/null
@@ -1,21 +0,0 @@
-{
- "extends": [
- "stylelint-config-standard",
- "stylelint-config-prettier"
- ],
- "rules": {
- "at-rule-no-unknown": [ true, {
- "ignoreAtRules": [
- "include",
- "mixin",
- "extend",
- "if",
- "each",
- "content",
- "import"
- ]
- }],
- "no-descending-specificity": null,
- "function-calc-no-invalid": null
- }
-}
diff --git a/.travis.yml b/.travis.yml
deleted file mode 100644
index 0e64c4a..0000000
--- a/.travis.yml
+++ /dev/null
@@ -1,22 +0,0 @@
-sudo: false
-
-dist: xenial
-
-services: mysql
-
-language: php
-
-php:
- - 5.6.30
- - 7.3
-
-env:
- - WP_VERSION=latest WP_MULTISITE=0
- - WP_VERSION=latest WP_MULTISITE=1
-
-before_script:
- - bash bin/install-wp-tests.sh wordpress_test root '' localhost $WP_VERSION
- - composer install
-
-script:
- - vendor/phpunit/phpunit/phpunit
diff --git a/404.php b/404.php
old mode 100755
new mode 100644
index e56dea2..da93a1c
--- a/404.php
+++ b/404.php
@@ -1,13 +1,17 @@
+Copyright (C)
+
+This program is free software; you can redistribute it and/or modify
+it under the terms of the GNU General Public License as published by
+the Free Software Foundation; either version 2 of the License, or
+(at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU General Public License for more details.
+
+You should have received a copy of the GNU General Public License along
+with this program; if not, write to the Free Software Foundation, Inc.,
+51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+
+Also add information on how to contact you by electronic and paper mail.
+
+If the program is interactive, make it output a short notice like this
+when it starts in an interactive mode:
+
+Gnomovision version 69, Copyright (C) year name of author
+Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
+This is free software, and you are welcome to redistribute it
+under certain conditions; type `show c' for details.
+
+The hypothetical commands `show w' and `show c' should show the appropriate
+parts of the General Public License. Of course, the commands you use may
+be called something other than `show w' and `show c'; they could even be
+mouse-clicks or menu items--whatever suits your program.
+
+You should also get your employer (if you work as a programmer) or your
+school, if any, to sign a "copyright disclaimer" for the program, if
+necessary. Here is a sample; alter the names:
+
+Yoyodyne, Inc., hereby disclaims all copyright interest in the program
+`Gnomovision' (which makes passes at compilers) written by James Hacker.
+
+, 1 April 1989
+Ty Coon, President of Vice
+
+This General Public License does not permit incorporating your program into
+proprietary programs. If your program is a subroutine library, you may
+consider it more useful to permit linking proprietary applications with the
+library. If this is what you want to do, use the GNU Lesser General
+Public License instead of this License.
diff --git a/README.md b/README.md
index a805df7..605392d 100644
--- a/README.md
+++ b/README.md
@@ -1,53 +1,164 @@
-[](https://www.emulsify.info/)
-
-> [!WARNING]
-> Emulsify for WordPress is no longer supported. If you'd like to be a contributor and bring it back to life, [reach out](https://www.emulsify.info/).
-
-# Emulsify Starter Theme
-
-## Installation
-
-### Composer
-- `composer require emulsify-ds/emulsify-wordpress-theme`
-- `cd` into theme; run `yarn setup`
-- Login to WordPress and make Emulsify your active theme.
-
-### Manual
-- Download this repo and put it in your WordPress `themes` folder.
-- `cd` into theme; run `yarn setup`
-- Login to WordPress and make Emulsify your active theme.
-
-### Contributors
-
-
+
+
+# Emulsify WordPress
+
+Emulsify WordPress 2.0.0 is a Timber-first WordPress parent theme for teams building component-driven sites with Emulsify Core 4, Vite, Storybook, and Twig.
+
+The parent theme provides the WordPress runtime: theme setup, Timber bootstrapping, Twig namespaces and helpers, template fallbacks, asset loading, and optional block registration. Generated child themes provide the project layer: components, templates, source Sass and JavaScript, compiled assets, and site-specific overrides.
+
+## Requirements
+
+- WordPress 6.7 or newer.
+- PHP 8.3 or newer.
+- Composer 2.
+- Node.js 24. Root release tooling expects `>=24.10`; generated child themes expect `>=24`.
+- Timber 2, preferably installed with Composer.
+- WP-CLI when generating child themes or running the full WordPress fixture smoke test.
+
+## Using Emulsify WordPress in a site project
+
+Install the parent theme as `emulsify` and pair it with a child theme for project work. In Bedrock, that usually means:
+
+```sh
+web/app/themes/emulsify
+web/app/themes/whisk
+```
+
+In a standard WordPress install, use:
+
+```sh
+wp-content/themes/emulsify
+wp-content/themes/whisk
+```
+
+Timber 2 must be loaded before the theme renders. Site projects can satisfy that requirement in either place:
+
+- Require `timber/timber` from the application-level Composer project.
+- Run Composer inside the parent theme when the parent theme owns its PHP dependencies:
+
+```sh
+cd web/app/themes/emulsify
+composer install
+```
+
+Activate the child theme, not the parent theme. The child theme header includes `Template: emulsify`, which tells WordPress to use Emulsify as the parent runtime.
+
+Do not run root npm commands in the parent theme for normal site implementation. Project frontend work happens in the generated child theme.
+
+## Working inside a generated child theme
+
+Run component, Vite, Storybook, and project lint commands from the generated child theme:
+
+```sh
+cd web/app/themes/whisk
+npm install
+```
+
+| Command | Purpose |
+| --- | --- |
+| `npm run build` | Build Core 4 assets with Vite. |
+| `npm run vite` | Watch and rebuild Vite assets. |
+| `npm run storybook` | Start Storybook on port 6006. |
+| `npm run develop` | Run the Vite watcher and Storybook together. |
+| `npm run storybook-build` | Build assets and export a static Storybook. |
+| `npm run lint` | Run JavaScript and Sass linting with the Core 4 config. |
+| `npm run audit` | Run the Core migration/static audit. |
+| `npm run audit:twig-stories` | Check Twig story compatibility. |
+| `npm run a11y` | Build Storybook and run the Core accessibility check. |
+| `npm run test` | Run Jest with `--passWithNoTests` for starter projects. |
+
+## Parent and child themes
+
+The `emulsify` parent theme owns reusable runtime behavior:
+
+- `includes/` contains the namespaced runtime classes.
+- `templates/` provides minimal Timber fallback templates.
+- `theme.json` provides editor settings and presets.
+- `whisk/` is the generated starter child theme source.
+
+The generated `whisk` child theme owns project implementation:
+
+- `whisk/src/components` is an empty placeholder until a project installs the component system it wants to use.
+- Source Sass, JavaScript, stories, data fixtures, and component metadata are defined by the selected Emulsify component system, not by this parent theme starter.
+- `whisk/assets/images` and `whisk/assets/icons` are empty placeholders for project-owned theme media and icon files.
+- `whisk/templates/page.twig` is a small example override.
+- `whisk/dist/global` and `whisk/dist/components` are runtime build output conventions when a component system emits them.
+- `whisk/project.emulsify.json` uses `"platform": "wordpress"` so Core and CLI tooling can load the WordPress platform adapter. It also records `generatedFrom` and `generatedFromVersion` so future upgrades and support diagnostics can identify Emulsify-generated WordPress child themes. Projects can also use Core-supported metadata such as `variant.structureImplementations` there when a selected component system needs legacy Twig namespaces.
+
+## Bedrock and Timber
+
+Timber is required for frontend template rendering. This repository declares `timber/timber` in the parent theme `composer.json`, so a standalone theme install can run Composer inside the parent theme.
+
+For Bedrock applications, it is also valid to require Timber from the application-level Composer project as long as WordPress loads that Composer autoloader before the theme renders. If Timber is missing, the parent theme shows an actionable admin notice and stops frontend rendering with a clear runtime error.
+
+## Developing or releasing the parent theme
+
+Parent-theme root commands are for maintainers and release checks, not normal project frontend development. Run them from the parent theme repository root:
+
+```sh
+composer install
+npm ci --ignore-scripts
+```
+
+Composer install creates the runtime autoloader. After adding or renaming parent runtime classes, run `composer dump-autoload` so Composer's optimized classmap sees the current files. Manual theme installs without Composer still use the Bootstrap fallback loader.
+
+| Command | Purpose |
+| --- | --- |
+| `npm run lint:php` | Lint all PHP files with `php -l`. |
+| `npm run pr:check` | Run the practical, stubbed pull request validation suite. |
+| `npm run release:check` | Run release-readiness checks. |
+| `npm run publish-test -- --no-ci` | Run a local semantic-release dry run. |
+
+Normal PR checks do not start MySQL or run the full WordPress fixture. `release:check` includes that fixture path, which requires WP-CLI and MySQL. It skips gracefully when WP-CLI or database settings are unavailable, and fails on missing fixture prerequisites when `WP_SMOKE_REQUIRED=1` is set.
+
+Before merging the 2.0 release branch, maintainers should run GitHub Actions > `WordPress Theme Readiness` on `release-2.x` with the `wordpress_fixture` input enabled. Success means both `Practical theme readiness` and `WordPress fixture smoke` pass. Release publishing also requires that full fixture path before semantic-release can publish.
+
+## Generate a child theme
+
+Generate a project child theme from the bundled Whisk starter with WP-CLI:
+
+```sh
+wp emulsify "Acme Site" --dry-run
+wp emulsify "Acme Site" --machine-name=acme-site
+wp emulsify "Acme Site" --machine-name=acme-site --force
+wp emulsify "Acme Site" --machine-name=acme-site --activate
+```
+
+The generator copies `emulsify/whisk` to a sibling child theme directory, updates WordPress theme headers, package metadata, Emulsify project metadata, and visible starter labels, then optionally activates the generated child theme.
+
+`--force` only replaces an existing destination when it already looks like an Emulsify-generated child theme. It refuses unrelated theme directories; use `--dry-run --force` to inspect replacement intent without deleting files.
+
+For Emulsify CLI integration, the standalone starter repository is `https://github.com/emulsify-ds/emulsify-wordpress-starter`. It represents the generated child theme layer from `whisk/`, not the parent runtime theme root, and generated projects still declare `Template: emulsify` so WordPress loads the installed parent theme.
+
+## Documentation
+
+- [Documentation index](docs/README.md)
+- [Upgrading from 1.x to 2.x](docs/upgrading-1x-to-2x.md)
+- [Sister-project parity contract](docs/sister-project-parity.md)
+- [Parent and child theme architecture](docs/parent-child-architecture.md)
+- [Timber and Twig authoring](docs/timber-and-twig-authoring.md)
+- [Emulsify Core 4 and Vite workflow](docs/core-4-vite-workflow.md)
+- [Component recipes](docs/component-recipes.md)
+- [ACF Local JSON](docs/acf-local-json.md)
+- [ACF/Twig blocks](docs/acf-twig-blocks.md)
+- [Core block Twig rendering](docs/core-block-twig-rendering.md)
+- [Native Gutenberg blocks](docs/native-gutenberg-blocks.md)
+- [Block patterns](docs/block-patterns.md)
+- [Editor enhancements](docs/editor-enhancements.md)
+- [Editor policy](docs/editor-policy.md)
+- [Asset loading](docs/asset-loading.md)
+- [WP-CLI child theme generation](docs/wp-cli-child-theme-generation.md)
+- [Release process](docs/release-process.md)
+- [Post-2.x optimization roadmap](docs/post-2x-optimization-roadmap.md)
+
+## License
+
+Emulsify WordPress is licensed under GPL-2.0-only. See [LICENSE](LICENSE).
+
+## Contributing
+
+Read the [Code of Conduct](https://github.com/emulsify-ds/emulsify-wordpress/blob/main/CODE_OF_CONDUCT.md) before contributing. File bugs and feature requests at [emulsify-ds/emulsify-wordpress](https://github.com/emulsify-ds/emulsify-wordpress/issues).
+
+## Author
+
+Emulsify® is a product of [Four Kitchens — We make BIG websites](https://fourkitchens.com).
diff --git a/a11y.config.js b/a11y.config.js
deleted file mode 100644
index 0cfbfd0..0000000
--- a/a11y.config.js
+++ /dev/null
@@ -1,62 +0,0 @@
-module.exports = {
- storybookBuildDir: '.out',
- pa11y: {
- includeNotices: false,
- includeWarnings: false,
- runners: ['axe'],
- },
- // A11y linting is done on a component-by-component
- // basis, which results in the linter reporting some errors that
- // should be ignored. These codes and descriptions allow for those
- // errors to be targeted specifically.
- ignore: {
- codes: ['landmark-one-main', 'page-has-heading-one'],
- descriptions: ['Ensures all page content is contained by landmarks'],
- },
- // List of storybook component IDs defined and used in this project.
- components: [
- 'base-colors--palettes',
- 'base-motion--usage',
- 'atoms-button--react',
- 'atoms-button--twig',
- 'atoms-button--twig-alt',
- 'atoms-forms--checkboxes',
- 'atoms-forms--radio-buttons',
- 'atoms-forms--select-dropdowns',
- 'atoms-forms--textfields-examples',
- 'atoms-images--images',
- 'atoms-images--figures',
- 'atoms-images--icons',
- 'atoms-links--links',
- 'atoms-lists--definition-list',
- 'atoms-lists--unordered-list',
- 'atoms-lists--ordered-list',
- 'atoms-tables--table',
- 'atoms-text--headings-examples',
- 'atoms-text--blockquote-example',
- 'atoms-text--preformatted',
- 'atoms-text--random',
- 'atoms-videos--wide',
- 'atoms-videos--full',
- 'molecules-cards--card-example',
- 'molecules-cards--card-with-background',
- 'molecules-cta--cta-example',
- 'molecules-menus--breadcrumbs',
- 'molecules-menus--inline',
- 'molecules-menus--main',
- 'molecules-menus--social',
- 'molecules-menus-pager--pager-example',
- 'molecules-status--status-examples',
- 'molecules-tabs--js-tabs',
- 'organisms-grids--default-grid',
- 'organisms-grids--card-grid',
- 'organisms-grids--cta-grid',
- 'organisms-site--footer',
- 'organisms-site--header',
- 'templates-layouts--full-width',
- 'templates-layouts--with-sidebar',
- 'templates-place-holder--place-holder',
- 'pages-content-types--article',
- 'pages-landing-pages--home',
- ],
-};
diff --git a/archive.php b/archive.php
old mode 100755
new mode 100644
index 148512a..b20c875
--- a/archive.php
+++ b/archive.php
@@ -1,40 +1,45 @@
$page_title,
+ )
+);
Timber::render( $templates, $context );
diff --git a/author.php b/author.php
old mode 100755
new mode 100644
index 456f256..1b1795b
--- a/author.php
+++ b/author.php
@@ -1,21 +1,26 @@
query_vars['author'] ) ) {
- $author = new Timber\User( $wp_query->query_vars['author'] );
- $context['author'] = $author;
- $context['title'] = 'Author Archives: ' . $author->name();
+if ( ! defined( 'ABSPATH' ) ) {
+ exit;
}
-Timber::render( array( 'author.twig', 'archive.twig' ), $context );
+
+// Author archives use the normal archive Twig fallback after adding an
+// author-specific title to the shared Timber context.
+$context = Timber::context();
+
+if ( isset( $context['author'] ) ) {
+ /* translators: %s is the author's display name. */
+ $context['title'] = sprintf( __( 'Archive of %s', 'emulsify' ), $context['author']->name() );
+}
+
+Timber::render(
+ array( '@templates/author.twig', '@templates/archive.twig' ),
+ $context
+);
diff --git a/babel.config.js b/babel.config.js
deleted file mode 100644
index c04d73d..0000000
--- a/babel.config.js
+++ /dev/null
@@ -1,19 +0,0 @@
-module.exports = (api) => {
- api.cache(true);
-
- const presets = [
- '@babel/preset-react',
- [
- '@babel/preset-env',
- {
- corejs: '3',
- useBuiltIns: 'usage',
- },
- ],
- 'minify',
- ];
-
- const comments = false;
-
- return { presets, comments };
-};
diff --git a/bin/install-wp-tests.sh b/bin/install-wp-tests.sh
deleted file mode 100644
index eba7bb4..0000000
--- a/bin/install-wp-tests.sh
+++ /dev/null
@@ -1,152 +0,0 @@
-#!/usr/bin/env bash
-
-if [ $# -lt 3 ]; then
- echo "usage: $0 [db-host] [wp-version] [skip-database-creation]"
- exit 1
-fi
-
-DB_NAME=$1
-DB_USER=$2
-DB_PASS=$3
-DB_HOST=${4-localhost}
-WP_VERSION=${5-latest}
-SKIP_DB_CREATE=${6-false}
-
-TMPDIR=${TMPDIR-/tmp}
-TMPDIR=$(echo $TMPDIR | sed -e "s/\/$//")
-WP_TESTS_DIR=${WP_TESTS_DIR-$TMPDIR/wordpress-tests-lib}
-WP_CORE_DIR=${WP_CORE_DIR-$TMPDIR/wordpress/}
-
-download() {
- if [ `which curl` ]; then
- curl -s "$1" > "$2";
- elif [ `which wget` ]; then
- wget -nv -O "$2" "$1"
- fi
-}
-
-if [[ $WP_VERSION =~ ^[0-9]+\.[0-9]+$ ]]; then
- WP_TESTS_TAG="branches/$WP_VERSION"
-elif [[ $WP_VERSION =~ [0-9]+\.[0-9]+\.[0-9]+ ]]; then
- if [[ $WP_VERSION =~ [0-9]+\.[0-9]+\.[0] ]]; then
- # version x.x.0 means the first release of the major version, so strip off the .0 and download version x.x
- WP_TESTS_TAG="tags/${WP_VERSION%??}"
- else
- WP_TESTS_TAG="tags/$WP_VERSION"
- fi
-elif [[ $WP_VERSION == 'nightly' || $WP_VERSION == 'trunk' ]]; then
- WP_TESTS_TAG="trunk"
-else
- # http serves a single offer, whereas https serves multiple. we only want one
- download http://api.wordpress.org/core/version-check/1.7/ /tmp/wp-latest.json
- grep '[0-9]+\.[0-9]+(\.[0-9]+)?' /tmp/wp-latest.json
- LATEST_VERSION=$(grep -o '"version":"[^"]*' /tmp/wp-latest.json | sed 's/"version":"//')
- if [[ -z "$LATEST_VERSION" ]]; then
- echo "Latest WordPress version could not be found"
- exit 1
- fi
- WP_TESTS_TAG="tags/$LATEST_VERSION"
-fi
-
-set -ex
-
-install_wp() {
-
- if [ -d $WP_CORE_DIR ]; then
- return;
- fi
-
- mkdir -p $WP_CORE_DIR
-
- if [[ $WP_VERSION == 'nightly' || $WP_VERSION == 'trunk' ]]; then
- mkdir -p $TMPDIR/wordpress-nightly
- download https://wordpress.org/nightly-builds/wordpress-latest.zip $TMPDIR/wordpress-nightly/wordpress-nightly.zip
- unzip -q $TMPDIR/wordpress-nightly/wordpress-nightly.zip -d $TMPDIR/wordpress-nightly/
- mv $TMPDIR/wordpress-nightly/wordpress/* $WP_CORE_DIR
- else
- if [ $WP_VERSION == 'latest' ]; then
- local ARCHIVE_NAME='latest'
- elif [[ $WP_VERSION =~ [0-9]+\.[0-9]+ ]]; then
- # https serves multiple offers, whereas http serves single.
- download https://api.wordpress.org/core/version-check/1.7/ $TMPDIR/wp-latest.json
- if [[ $WP_VERSION =~ [0-9]+\.[0-9]+\.[0] ]]; then
- # version x.x.0 means the first release of the major version, so strip off the .0 and download version x.x
- LATEST_VERSION=${WP_VERSION%??}
- else
- # otherwise, scan the releases and get the most up to date minor version of the major release
- local VERSION_ESCAPED=`echo $WP_VERSION | sed 's/\./\\\\./g'`
- LATEST_VERSION=$(grep -o '"version":"'$VERSION_ESCAPED'[^"]*' $TMPDIR/wp-latest.json | sed 's/"version":"//' | head -1)
- fi
- if [[ -z "$LATEST_VERSION" ]]; then
- local ARCHIVE_NAME="wordpress-$WP_VERSION"
- else
- local ARCHIVE_NAME="wordpress-$LATEST_VERSION"
- fi
- else
- local ARCHIVE_NAME="wordpress-$WP_VERSION"
- fi
- download https://wordpress.org/${ARCHIVE_NAME}.tar.gz $TMPDIR/wordpress.tar.gz
- tar --strip-components=1 -zxmf $TMPDIR/wordpress.tar.gz -C $WP_CORE_DIR
- fi
-
- download https://raw.github.com/markoheijnen/wp-mysqli/master/db.php $WP_CORE_DIR/wp-content/db.php
-}
-
-install_test_suite() {
- # portable in-place argument for both GNU sed and Mac OSX sed
- if [[ $(uname -s) == 'Darwin' ]]; then
- local ioption='-i .bak'
- else
- local ioption='-i'
- fi
-
- # set up testing suite if it doesn't yet exist
- if [ ! -d $WP_TESTS_DIR ]; then
- # set up testing suite
- mkdir -p $WP_TESTS_DIR
- svn co --quiet https://develop.svn.wordpress.org/${WP_TESTS_TAG}/tests/phpunit/includes/ $WP_TESTS_DIR/includes
- svn co --quiet https://develop.svn.wordpress.org/${WP_TESTS_TAG}/tests/phpunit/data/ $WP_TESTS_DIR/data
- fi
-
- if [ ! -f wp-tests-config.php ]; then
- download https://develop.svn.wordpress.org/${WP_TESTS_TAG}/wp-tests-config-sample.php "$WP_TESTS_DIR"/wp-tests-config.php
- # remove all forward slashes in the end
- WP_CORE_DIR=$(echo $WP_CORE_DIR | sed "s:/\+$::")
- sed $ioption "s:dirname( __FILE__ ) . '/src/':'$WP_CORE_DIR/':" "$WP_TESTS_DIR"/wp-tests-config.php
- sed $ioption "s/youremptytestdbnamehere/$DB_NAME/" "$WP_TESTS_DIR"/wp-tests-config.php
- sed $ioption "s/yourusernamehere/$DB_USER/" "$WP_TESTS_DIR"/wp-tests-config.php
- sed $ioption "s/yourpasswordhere/$DB_PASS/" "$WP_TESTS_DIR"/wp-tests-config.php
- sed $ioption "s|localhost|${DB_HOST}|" "$WP_TESTS_DIR"/wp-tests-config.php
- fi
-
-}
-
-install_db() {
-
- if [ ${SKIP_DB_CREATE} = "true" ]; then
- return 0
- fi
-
- # parse DB_HOST for port or socket references
- local PARTS=(${DB_HOST//\:/ })
- local DB_HOSTNAME=${PARTS[0]};
- local DB_SOCK_OR_PORT=${PARTS[1]};
- local EXTRA=""
-
- if ! [ -z $DB_HOSTNAME ] ; then
- if [ $(echo $DB_SOCK_OR_PORT | grep -e '^[0-9]\{1,\}$') ]; then
- EXTRA=" --host=$DB_HOSTNAME --port=$DB_SOCK_OR_PORT --protocol=tcp"
- elif ! [ -z $DB_SOCK_OR_PORT ] ; then
- EXTRA=" --socket=$DB_SOCK_OR_PORT"
- elif ! [ -z $DB_HOSTNAME ] ; then
- EXTRA=" --host=$DB_HOSTNAME --protocol=tcp"
- fi
- fi
-
- # create database
- mysqladmin create $DB_NAME --user="$DB_USER" --password="$DB_PASS"$EXTRA
-}
-
-install_wp
-install_test_suite
-install_db
\ No newline at end of file
diff --git a/commitlint.config.js b/commitlint.config.js
new file mode 100644
index 0000000..6e53bd4
--- /dev/null
+++ b/commitlint.config.js
@@ -0,0 +1,5 @@
+module.exports = {
+ // Conventional Commits drive semantic-release, so keep this config minimal
+ // and aligned with the release parser in release.config.js.
+ extends: ['@commitlint/config-conventional'],
+};
diff --git a/components/00-base/00-defaults/_01-variables.scss b/components/00-base/00-defaults/_01-variables.scss
deleted file mode 100644
index 3a9d52f..0000000
--- a/components/00-base/00-defaults/_01-variables.scss
+++ /dev/null
@@ -1,39 +0,0 @@
-/*
- * General Variables
- * Note: colors should go into
- * components/00-base/01-colors/_color-vars.css
- * and breakpoint related vars into
- * components/00-base/base/_breakpoints.css
-*/
-
-// Fonts
-$font-body: georgia, times, 'Times New Roman', serif;
-$font-heading: 'HelveticaNeue', 'Helvetica', 'Arial', sans-serif;
-
-// Spacing
-$space: 1rem;
-
-// Times
-$space-double: $space * 2;
-$space-triple: $space * 3;
-$space-quadruple: $space * 4;
-$space-quintuple: $space * 5;
-$space-sextuple: $space * 6;
-$space-septuple: $space * 7;
-
-// Divided
-$space-one-half: $space/2;
-$space-one-third: $space/3;
-$space-one-fourth: $space/4;
-$space-one-fifth: $space/5;
-$space-one-sixth: $space/6;
-$space-one-seventh: $space/7;
-$space-one-eighth: $space/8;
-
-/*
- * Use this on the outer wrapper of page-level elements.
- * It ensures consistent spacing between elements on the page.
- */
-@mixin space-stack-page {
- margin-bottom: $space-double;
-}
diff --git a/components/00-base/00-defaults/_02-breakpoints.scss b/components/00-base/00-defaults/_02-breakpoints.scss
deleted file mode 100644
index 4b52208..0000000
--- a/components/00-base/00-defaults/_02-breakpoints.scss
+++ /dev/null
@@ -1,68 +0,0 @@
-/**
- * Breakpoint Configuration
- * @see https://github.com/Team-Sass/breakpoint/wiki
- *
- */
-
-/////////////////////
-// Global Breakpoints
-
-// Avoid using in favor of atomic, content-specific, breakpoints.
-// These should be used for generic code, like layouts and typography, only.
-
-$xs: 320px;
-$small: 480px;
-$medium: 720px;
-$large: 920px;
-$xl: 1224px;
-// The max-width breakpoint is used when the design should be applied at whatever the
-// largest breakpoint is regardless of actual pixel value. e.g. removing outer margin on body wrapper
-$max-width: $xl;
-
-/// Mixin - xs Breakpoint
-/// Allows easier @include xs {} syntax
-@mixin xs {
- @include breakpoint($xs) {
- @content;
- }
-}
-
-/// Mixin - small Breakpoint
-/// Allows easier @include small {} syntax
-@mixin small {
- @include breakpoint($small) {
- @content;
- }
-}
-
-/// Mixin - medium Breakpoint
-/// Allows easier @include medium {} syntax
-@mixin medium {
- @include breakpoint($medium) {
- @content;
- }
-}
-
-/// Mixin - large Breakpoint
-/// Allows easier @include large {} syntax
-@mixin large {
- @include breakpoint($large) {
- @content;
- }
-}
-
-/// Mixin - xl Breakpoint
-/// Allows easier @include xl {} syntax
-@mixin xl {
- @include breakpoint($xl) {
- @content;
- }
-}
-
-/// Mixin - max-width Breakpoint
-/// Allows easier @include max-width {} syntax
-@mixin max-width {
- @include breakpoint($max-width) {
- @content;
- }
-}
diff --git a/components/00-base/00-defaults/_03-mixins.scss b/components/00-base/00-defaults/_03-mixins.scss
deleted file mode 100644
index c36b696..0000000
--- a/components/00-base/00-defaults/_03-mixins.scss
+++ /dev/null
@@ -1,68 +0,0 @@
-/*
- * General Mixins (put specific ones in component files where applicable)
-*/
-
-/* Mixin - Clearfix.
- * Adds clearfix based on http://bourbon.io/docs/#clearfix
- * use example = @include cleafix
-*/
-
-@mixin body-copy {
- font-family: $font-body;
- font-size: 1rem;
- line-height: 1.6;
-}
-
-@mixin clearfix {
- &::after {
- clear: both;
- content: '';
- display: table;
- }
-}
-
-$outer-container-break: $small;
-
-/// Mixin - Wrapper
-/// Outer container mixin for large screens
-@mixin wrapper(
- $container-max-width: $max-width,
- $outer-container-break: $small,
- $v-margin: 0,
- $v-padding: 0,
- $h-padding: $space,
- $h-padding-large: $h-padding
-) {
- max-width: #{$container-max-width};
- width: 100%;
- margin: #{$v-margin} auto;
- padding: #{$v-padding} #{$h-padding};
-
- @include breakpoint($outer-container-break) {
- padding: #{$v-padding} #{$h-padding-large};
- }
-
- @include breakpoint($container-max-width) {
- padding-left: calc(
- #{$h-padding-large} + calc(-50vw + calc(#{$container-max-width} / 2))
- );
- padding-right: calc(
- #{$h-padding-large} + calc(-50vw + calc(#{$container-max-width} / 2))
- );
- }
-}
-
-// Mixin - Standard Margin
-@mixin margin {
- margin-bottom: 1em;
-}
-
-@mixin no-bottom {
- margin-bottom: 0;
-}
-
-@mixin list-reset {
- list-style: none;
- margin: 0;
- padding: 0;
-}
diff --git a/components/00-base/01-colors/_00-color-vars.scss b/components/00-base/01-colors/_00-color-vars.scss
deleted file mode 100644
index 236f0b3..0000000
--- a/components/00-base/01-colors/_00-color-vars.scss
+++ /dev/null
@@ -1,23 +0,0 @@
-// Color variables - it is better to use the colors in
-// components/00-base/01-colors/_01-colors-used.scss rather than
-// these colors directly.
-
-// Grayscale
-$white: white;
-$near-white: #f2f2f2;
-$gray-lightest: #e5e5e5;
-$gray-lighter: #ccc;
-$gray-light: #888;
-$gray: #666;
-$gray-dark: #4c4c4c;
-$gray-darker: #333;
-$black: black;
-
-// Status
-$yellow-light: #f9fb93;
-$red-light: #fbe3e4;
-$green-light: #cfefc2;
-
-// Project
-$purple: #610c63;
-$blue: #005de0;
diff --git a/components/00-base/01-colors/_01-colors-used.scss b/components/00-base/01-colors/_01-colors-used.scss
deleted file mode 100644
index 1216324..0000000
--- a/components/00-base/01-colors/_01-colors-used.scss
+++ /dev/null
@@ -1,98 +0,0 @@
-// Color Usage variables - use these (via the `clr` function below)
-// rather than color variables directly.
-$defaultColors: (
- text: $gray,
- text-inverse: $white,
- background: $white,
- background-section: $gray-lightest,
- primary: $blue,
- secondary: $purple,
- accent: $gray-dark,
- accent-high: $black,
- highlight: $gray-lighter,
- highlight-high: $gray-lightest,
- muted: $near-white,
- warning: $yellow-light,
- error: $red-light,
- message: $green-light,
-);
-
-$darkColors: (
- text: $white,
- text-inverse: $gray,
- background: $gray,
- background-inverse: $gray-light,
- background-section: $gray-dark,
- primary: $blue,
- secondary: $purple,
- accent: $gray-lightest,
- accent-high: $white,
- highlight: $gray-dark,
- highlight-high: $gray-darker,
- muted: $gray-darker,
- warning: $yellow-light,
- error: $red-light,
- message: $green-light,
-);
-
-///////////
-// Usage //
-///////////
-// This color function makes it easy for you to call a color by it's functional
-// name anywhere you need.
-// E.g. to use the `text` color, you type: `color: clr(text);`
-//
-// How it works:
-// This function works in conjunction with the dynamic css custom properties
-// declarations below. The item called in the function is prefixed with
-// `var(--c-` to call the custom property for that color.
-// E.g. `color: clr(text);` will return `color: var(--c-text);`
-// Note: (The `--c-` is added via the `@each` loops below)
-@function clr($colorChoice) {
- @return var(--c-#{$colorChoice});
-}
-
-// Create CSS custom properties
-// This is what powers the built-in OS light/dark mode switching.
-// For each color variable above (e.g. `text` and `background`) a css custom
-// property will be created. (e.g. `--c-text:` and `--c-background:`).
-//
-// The first `@each` below will create a custom property for each item in the
-// `$defaultColors` map. So put all of your default colors there.
-//
-// The `prefers-color-scheme: dark` media query below will then create custom
-// properties for each of the colors in the `$darkColors` map only if the
-// browser supports that feature. If there is an exact match (e.g. `text`) in
-// both maps, the one from `$darkColors` will be used on machines that have
-// opted to use the 'dark mode' on their OS.
-:root {
- // Create custom properties for default colors
- @each $name, $color in $defaultColors {
- --c-#{$name}: #{$color};
- }
-
- // Create custom properties for dark colors, an duse them if the user's OS has dark mode enabled
- @media (prefers-color-scheme: dark) {
- @each $name, $color in $darkColors {
- --c-#{$name}: #{$color};
- }
- }
-
- // Use the default color scheme for the "Default Theme" section of the
- // component library regardless of OS setting.
- // This is required to show the correct colors in the component library at all times
- [data-theme='default'] {
- @each $name, $color in $defaultColors {
- --c-#{$name}: #{$color};
- }
- }
-
- // Use the dark color scheme for the "Dark Theme" section of the component
- // library regardless of OS setting.
- // This is required to show the correct colors in the component library at all times
- [data-theme='dark'] {
- @each $name, $color in $darkColors {
- --c-#{$name}: #{$color};
- }
- }
-}
diff --git a/components/00-base/01-colors/_02-colors-component-library.scss b/components/00-base/01-colors/_02-colors-component-library.scss
deleted file mode 100644
index 56fe12c..0000000
--- a/components/00-base/01-colors/_02-colors-component-library.scss
+++ /dev/null
@@ -1,56 +0,0 @@
-// These styles only affect the colors "Usage" page in the component library
-.cl-colors {
- padding: 1rem;
-}
-
-.cl-colors__list {
- display: flex;
- flex-wrap: wrap;
- margin: 0 0 2rem;
- padding: 0;
-}
-
-.cl-colors__item {
- list-style: none;
- padding: 1rem 2rem;
- transition: all 0.4s;
- flex: 1 1 20%;
- min-width: 150px;
- min-height: 150px;
- display: flex;
- justify-content: center;
- align-items: flex-end;
-}
-
-// Dynamically set swatch text color based on the lightness of the background color
-@function set-color(
- $color,
- $text-primary: accent-high,
- $text-secondary: muted
-) {
- @if (lightness($color) > 50) {
- @return clr($text-primary);
- } @else {
- @return clr($text-secondary);
- }
-}
-
-// Style default color swatches
-@each $name, $color in $defaultColors {
- .cl-colors__item--default {
- &-#{$name} {
- background-color: clr($name);
- color: set-color($color);
- }
- }
-}
-
-// Style dark color swatches
-@each $name, $color in $darkColors {
- .cl-colors__item--dark {
- &-#{$name} {
- background-color: clr($name);
- color: set-color($color, muted, accent-high);
- }
- }
-}
diff --git a/components/00-base/01-colors/colors.stories.js b/components/00-base/01-colors/colors.stories.js
deleted file mode 100644
index 54869bd..0000000
--- a/components/00-base/01-colors/colors.stories.js
+++ /dev/null
@@ -1,14 +0,0 @@
-import React from 'react';
-
-import colors from './colors.twig';
-
-import colorsData from './colors.yml';
-
-/**
- * Storybook Definition.
- */
-export default { title: 'Base/Colors' };
-
-export const Palettes = () => (
-
-);
diff --git a/components/00-base/01-colors/colors.twig b/components/00-base/01-colors/colors.twig
deleted file mode 100644
index 3a27599..0000000
--- a/components/00-base/01-colors/colors.twig
+++ /dev/null
@@ -1,27 +0,0 @@
-{% set colors_base_class = "cl-colors" %}
-
-
- {% include "@atoms/text/headings/_heading.twig" with {
- heading_level: 2,
- heading: colors_heading,
- } %}
-
- {% for palette in palettes %}
- {% set palette_machine = palette.palette_heading|lower %}
- {% include "@atoms/text/headings/_heading.twig" with {
- heading_level: 3,
- heading: palette.palette_heading,
- } %}
-
- {% for color in palette.colors %}
- {% set color_machine = color.name|lower|replace({' ': '-'}) %}
- {% if color.color_reverse == TRUE %}
- {% set colors_title_modifiers = ['reverse'] %}
- {% endif %}
-
- {{ color.name }}
-
- {% endfor %}
-
- {% endfor %}
-
diff --git a/components/00-base/01-colors/colors.yml b/components/00-base/01-colors/colors.yml
deleted file mode 100644
index a6e98ad..0000000
--- a/components/00-base/01-colors/colors.yml
+++ /dev/null
@@ -1,30 +0,0 @@
-colors_heading: 'Palettes:'
-palettes:
- - palette_heading: 'Default'
- colors:
- - name: 'Text'
- - name: 'Background'
- - name: 'Primary'
- - name: 'Secondary'
- - name: 'Accent'
- - name: 'Accent High'
- - name: 'Highlight'
- - name: 'Highlight High'
- - name: 'Muted'
- - name: 'Warning'
- - name: 'Error'
- - name: 'Message'
- - palette_heading: 'Dark'
- colors:
- - name: 'Text'
- - name: 'Background'
- - name: 'Primary'
- - name: 'Secondary'
- - name: 'Accent'
- - name: 'Accent High'
- - name: 'Highlight'
- - name: 'Highlight High'
- - name: 'Muted'
- - name: 'Warning'
- - name: 'Error'
- - name: 'Message'
diff --git a/components/00-base/02-motion/_motion.scss b/components/00-base/02-motion/_motion.scss
deleted file mode 100644
index 67d41b6..0000000
--- a/components/00-base/02-motion/_motion.scss
+++ /dev/null
@@ -1,118 +0,0 @@
-/* Mixin - Transition */
-$transition-duration-base: 0.3s;
-$transition-timing-function-base: ease-in-out;
-
-@mixin transition(
- $transition-property: all,
- $transition-duration: $transition-duration-base,
- $transition-timing-function: $transition-timing-function-base
-) {
- transition: $transition-property $transition-duration
- $transition-timing-function;
-}
-
-// Demo UI
-.motion {
- padding: 0 1rem;
-}
-
-.motion__grid {
- @media (min-width: 920px) {
- display: flex;
- flex-wrap: wrap;
- }
-}
-
-.motion__grid-item {
- @media (min-width: 920px) {
- margin-bottom: 1rem;
- margin-right: 1rem;
- width: 49%;
-
- &:nth-child(2n) {
- margin-right: 0;
- }
- }
-}
-
-.demo-motion {
- background-color: clr(highlight-high);
- border-radius: 2px;
- cursor: pointer;
- padding: 3rem;
- text-align: center;
-
- &::before {
- content: 'Duration: #{$transition-duration-base}';
- display: block;
- }
-
- &::after {
- content: 'Timing Function: #{$transition-timing-function-base}';
- }
-
- span {
- display: block;
- margin: 0.25rem;
- }
-}
-
-.demo-motion--fade {
- @include transition;
-
- &:hover {
- background-color: clr(accent-high);
- color: clr(muted);
- }
-}
-
-.demo-motion--slide-up {
- @include transition(transform, 0.4s, ease-in);
-
- &::before {
- content: 'Duration: 0.4s';
- display: block;
- }
-
- &::after {
- content: 'Timing Function: ease-in';
- }
-
- &:hover {
- transform: translateY(-10px);
- }
-}
-
-.demo-motion--slide-down {
- @include transition(transform, 0.2s, linear);
-
- &::before {
- content: 'Duration: 0.2s';
- display: block;
- }
-
- &::after {
- content: 'Timing Function: linear';
- }
-
- &:hover {
- transform: translateY(10px);
- }
-}
-
-.demo-motion--expand {
- @include transition(transform, 0.3s, cubic-bezier(0.17, 0.67, 0.83, 0.67));
-
- &::before {
- content: 'Duration: 0.3s';
- display: block;
- }
-
- &::after {
- content: 'Timing Function: cubic-bezier(.17,.67,.83,.67)';
- }
-
- &:hover {
- transform: scale(1.03);
- }
-}
diff --git a/components/00-base/02-motion/motion.stories.js b/components/00-base/02-motion/motion.stories.js
deleted file mode 100644
index 14135b7..0000000
--- a/components/00-base/02-motion/motion.stories.js
+++ /dev/null
@@ -1,18 +0,0 @@
-import React from 'react';
-
-import motion from './motion.twig';
-
-import motionData from './motion.yml';
-
-/**
- * Add storybook definition for Animations.
- */
-export default { title: 'Base/Motion' };
-
-export const Usage = () => (
-
-);
diff --git a/components/00-base/02-motion/motion.twig b/components/00-base/02-motion/motion.twig
deleted file mode 100644
index 594c491..0000000
--- a/components/00-base/02-motion/motion.twig
+++ /dev/null
@@ -1,11 +0,0 @@
-
-
Motion
-
- {% for motion in motions %}
-
- {% endfor %}
-
-
diff --git a/components/00-base/02-motion/motion.yml b/components/00-base/02-motion/motion.yml
deleted file mode 100644
index 6f1c886..0000000
--- a/components/00-base/02-motion/motion.yml
+++ /dev/null
@@ -1,5 +0,0 @@
-motions:
- - name: 'Fade'
- - name: 'Slide Up'
- - name: 'Slide Down'
- - name: 'Expand'
diff --git a/components/00-base/03-site/_base.scss b/components/00-base/03-site/_base.scss
deleted file mode 100644
index 6dbf12e..0000000
--- a/components/00-base/03-site/_base.scss
+++ /dev/null
@@ -1,31 +0,0 @@
-html {
- box-sizing: border-box;
-}
-
-body {
- background-color: clr(background);
- color: clr(text);
-
- @include body-copy;
-}
-
-*,
-*::after,
-*::before {
- box-sizing: inherit;
-}
-
-.main {
- @include wrapper;
-
- display: block; /* Fix for IE weirdness */
-}
-
-.visually-hidden {
- position: absolute !important;
- clip: rect(1px, 1px, 1px, 1px);
- overflow: hidden;
- height: 1px;
- width: 1px;
- word-wrap: normal;
-}
diff --git a/components/01-atoms/buttons/_buttons.scss b/components/01-atoms/buttons/_buttons.scss
deleted file mode 100644
index 5df5faa..0000000
--- a/components/01-atoms/buttons/_buttons.scss
+++ /dev/null
@@ -1,100 +0,0 @@
-// Sass map to define colors. Each set should have a "Dark" variation when required.
-// e.g. `default` and `default-dark`. Or `purple` and `purple-dark`.
-$button-colors: (
- primary: (
- text: clr(text-inverse),
- bg: clr(primary),
- text-hover: clr(primary),
- bg-hover: clr(highlight),
- ),
- primary-dark: (
- text: clr(text),
- bg: clr(primary),
- text-hover: clr(text),
- bg-hover: clr(highlight-high),
- ),
- secondary: (
- text: clr(text-inverse),
- bg: clr(secondary),
- text-hover: clr(secondary),
- bg-hover: clr(highlight),
- ),
- secondary-dark: (
- text: clr(text),
- bg: clr(secondary),
- text-hover: clr(text),
- bg-hover: clr(highlight-high),
- ),
-);
-
-// Mixin to define colors for one or more schemes.
-// Simply pass the color scheme to the mixin to get the colors defined in the map.
-// e.g. @include buttonColors(default) will get all of the colors defined in the "default" section.
-@mixin buttonColors(
- $scheme,
- $pallate: map-get($button-colors, $scheme),
- $map: $button-colors
-) {
- color: map-get($pallate, text);
- background-color: map-get($pallate, bg);
-
- &:visited {
- color: map-get($pallate, text);
- }
-
- &:hover {
- color: map-get($pallate, text-hover);
- background-color: map-get($pallate, bg-hover);
- }
-}
-
-// The button-base mixin contains styles that apply to all buttons
-// regardless of color or size.
-@mixin button-base {
- border: none;
- cursor: pointer;
- display: inline-block;
- text-decoration: none;
- text-align: center;
- text-transform: uppercase;
-}
-
-// Button color variations
-// Note how we include dark mode options for each
-@mixin button-color-primary {
- @include buttonColors(primary);
-
- @media (prefers-color-scheme: dark) {
- @include buttonColors(primary-dark);
- }
-}
-
-@mixin button-color-secondary {
- @include buttonColors(secondary);
-
- @media (prefers-color-scheme: dark) {
- @include buttonColors(secondary-dark);
- }
-}
-
-// Button size variations
-@mixin button-medium {
- line-height: 1.4;
- padding: $space-one-half $space;
-}
-
-@mixin button-large {
- line-height: 2;
- padding: $space $space-double;
-}
-
-.button {
- @include button-base;
- @include button-color-primary;
- @include button-medium;
-
- &--alt {
- @include button-color-secondary;
- @include button-large;
- }
-}
diff --git a/components/01-atoms/buttons/buttons.stories.js b/components/01-atoms/buttons/buttons.stories.js
deleted file mode 100644
index ae00e8f..0000000
--- a/components/01-atoms/buttons/buttons.stories.js
+++ /dev/null
@@ -1,29 +0,0 @@
-import React from 'react';
-
-import Button from './react/Button.component';
-
-import button from './twig/button.twig';
-
-import buttonData from './twig/button.yml';
-import buttonAltData from './twig/button-alt.yml';
-import buttonAlt2Data from './twig/button-alt2.yml';
-
-/**
- * Storybook Definition.
- */
-export default {
- component: Button,
- title: 'Atoms/Button',
-};
-
-export const react = () => React Button ;
-
-export const twig = () => (
-
-);
-export const twigAlt = () => (
-
-);
-export const twigAlt2 = () => (
-
-);
diff --git a/components/01-atoms/buttons/react/Button.component.js b/components/01-atoms/buttons/react/Button.component.js
deleted file mode 100644
index 74d5d9a..0000000
--- a/components/01-atoms/buttons/react/Button.component.js
+++ /dev/null
@@ -1,32 +0,0 @@
-/**
- * @file Button.component.js
- * Exports a button component.
- */
-
-import React from 'react';
-import PropTypes from 'prop-types';
-
-/**
- * Component that renders a button with a click handler.
- */
-const Button = (props) => {
- const { onClick, children } = props;
-
- return (
-
- {children}
-
- );
-};
-
-Button.propTypes = {
- onClick: PropTypes.func,
- children: PropTypes.node,
-};
-
-Button.defaultProps = {
- children: null,
- onClick: () => {},
-};
-
-export default Button;
diff --git a/components/01-atoms/buttons/twig/button-alt.yml b/components/01-atoms/buttons/twig/button-alt.yml
deleted file mode 100644
index 73aedee..0000000
--- a/components/01-atoms/buttons/twig/button-alt.yml
+++ /dev/null
@@ -1,4 +0,0 @@
-button_content: 'Alternative Button'
-button_url: '#'
-button_modifiers:
- - 'alt'
diff --git a/components/01-atoms/buttons/twig/button-alt2.yml b/components/01-atoms/buttons/twig/button-alt2.yml
deleted file mode 100644
index bb71b3e..0000000
--- a/components/01-atoms/buttons/twig/button-alt2.yml
+++ /dev/null
@@ -1,4 +0,0 @@
-button_content: 'Second Alternative Button'
-button_url: '#'
-button_modifiers:
- - 'alt-2'
diff --git a/components/01-atoms/buttons/twig/button.twig b/components/01-atoms/buttons/twig/button.twig
deleted file mode 100644
index ba6c3fa..0000000
--- a/components/01-atoms/buttons/twig/button.twig
+++ /dev/null
@@ -1,22 +0,0 @@
-{#
-/**
- * Available variables:
- * - button_content - the content of the button (typically text)
- *
- * Available blocks:
- * - button_content - used to replace the content of the button with something other than text
- * for example: to insert an icon
- */
-#}
-
-{% set button_base_class = button_base_class|default('button') %}
-
-{% set additional_attributes = {
- class: bem(button_base_class, button_modifiers, button_blockname),
-} %}
-
-
- {% block button_content %}
- {{ button_content }}
- {% endblock %}
-
diff --git a/components/01-atoms/buttons/twig/button.yml b/components/01-atoms/buttons/twig/button.yml
deleted file mode 100644
index 1ddc203..0000000
--- a/components/01-atoms/buttons/twig/button.yml
+++ /dev/null
@@ -1,2 +0,0 @@
-button_content: "Default Button"
-button_url: "#"
diff --git a/components/01-atoms/forms/checkbox/_checkbox-item.twig b/components/01-atoms/forms/checkbox/_checkbox-item.twig
deleted file mode 100644
index db5a47e..0000000
--- a/components/01-atoms/forms/checkbox/_checkbox-item.twig
+++ /dev/null
@@ -1,5 +0,0 @@
-
-
- {{ checkbox_item }}
-
-
diff --git a/components/01-atoms/forms/checkbox/_checkbox.scss b/components/01-atoms/forms/checkbox/_checkbox.scss
deleted file mode 100644
index 09a07e0..0000000
--- a/components/01-atoms/forms/checkbox/_checkbox.scss
+++ /dev/null
@@ -1,4 +0,0 @@
-.form-item--checkboxes,
-.form-item--checkbox__item {
- @include list-reset;
-}
diff --git a/components/01-atoms/forms/checkbox/checkbox.twig b/components/01-atoms/forms/checkbox/checkbox.twig
deleted file mode 100644
index 2f4d67e..0000000
--- a/components/01-atoms/forms/checkbox/checkbox.twig
+++ /dev/null
@@ -1,15 +0,0 @@
-{% if not attributes %}
-
- Options as Checkboxes
-
-
-{% endif %}
diff --git a/components/01-atoms/forms/checkbox/checkbox.yml b/components/01-atoms/forms/checkbox/checkbox.yml
deleted file mode 100644
index 93a73fd..0000000
--- a/components/01-atoms/forms/checkbox/checkbox.yml
+++ /dev/null
@@ -1,6 +0,0 @@
-checkboxes:
- - title: 'Option 1'
- checked: 'checked'
- - title: 'Option 2'
- - title: 'Option 3'
- - title: 'Option 4'
diff --git a/components/01-atoms/forms/forms.stories.js b/components/01-atoms/forms/forms.stories.js
deleted file mode 100644
index 53889d0..0000000
--- a/components/01-atoms/forms/forms.stories.js
+++ /dev/null
@@ -1,28 +0,0 @@
-import React from 'react';
-
-import checkbox from './checkbox/checkbox.twig';
-import radio from './radio/radio.twig';
-import select from './select/select.twig';
-import textfields from './textfields/textfields.twig';
-
-import checkboxData from './checkbox/checkbox.yml';
-import radioData from './radio/radio.yml';
-import selectOptionsData from './select/select.yml';
-
-/**
- * Storybook Definition.
- */
-export default { title: 'Atoms/Forms' };
-
-export const checkboxes = () => (
-
-);
-export const radioButtons = () => (
-
-);
-export const selectDropdowns = () => (
-
-);
-export const textfieldsExamples = () => (
-
-);
diff --git a/components/01-atoms/forms/radio/_radio-item.twig b/components/01-atoms/forms/radio/_radio-item.twig
deleted file mode 100644
index 33b95e7..0000000
--- a/components/01-atoms/forms/radio/_radio-item.twig
+++ /dev/null
@@ -1,5 +0,0 @@
-
-
- {{ radio_item }}
-
-
diff --git a/components/01-atoms/forms/radio/_radio.scss b/components/01-atoms/forms/radio/_radio.scss
deleted file mode 100644
index 872994e..0000000
--- a/components/01-atoms/forms/radio/_radio.scss
+++ /dev/null
@@ -1,4 +0,0 @@
-.form-item--radios,
-.form-item--radio__item {
- @include list-reset;
-}
diff --git a/components/01-atoms/forms/radio/radio.twig b/components/01-atoms/forms/radio/radio.twig
deleted file mode 100644
index cc65c2c..0000000
--- a/components/01-atoms/forms/radio/radio.twig
+++ /dev/null
@@ -1,15 +0,0 @@
-{% if not attributes %}
-
- Options as Radio Buttons
-
-
-{% endif %}
diff --git a/components/01-atoms/forms/radio/radio.yml b/components/01-atoms/forms/radio/radio.yml
deleted file mode 100644
index 278b78e..0000000
--- a/components/01-atoms/forms/radio/radio.yml
+++ /dev/null
@@ -1,6 +0,0 @@
-radios:
- - title: 'Option 1'
- checked: 'checked'
- - title: 'Option 2'
- - title: 'Option 3'
- - title: 'Option 4'
diff --git a/components/01-atoms/forms/select/_select-item.twig b/components/01-atoms/forms/select/_select-item.twig
deleted file mode 100644
index a38afcb..0000000
--- a/components/01-atoms/forms/select/_select-item.twig
+++ /dev/null
@@ -1 +0,0 @@
-{{ select_item }}
diff --git a/components/01-atoms/forms/select/_select.scss b/components/01-atoms/forms/select/_select.scss
deleted file mode 100644
index eb3e8a6..0000000
--- a/components/01-atoms/forms/select/_select.scss
+++ /dev/null
@@ -1,72 +0,0 @@
-// CSS-only select styling (from https://github.com/filamentgroup/select-css)
-
-.form-item__dropdown {
- border: 1px solid clr(muted);
- display: block;
- position: relative;
-
- @media (prefers-color-scheme: dark) {
- background-color: clr(background-inverse);
- }
-
- &::after {
- border-left: 5px solid transparent;
- border-right: 5px solid transparent;
- border-top: 9px solid clr(accent);
- content: ' ';
- position: absolute;
- top: 42%;
- right: 1em;
- z-index: 2;
-
- /* These hacks make the select behind the arrow clickable in some browsers */
- pointer-events: none;
- display: none;
- }
-
- &:hover {
- border-color: clr(accent-high);
- }
-}
-
-.form-item__select {
- border: 1px solid clr(muted);
- height: 41px; // set height required for discrepancy between .form-item__dropdown border and the select :focus border
- font-size: 16px;
- margin: 0;
- outline: none;
- padding: 0.6em 0.8em 0.5em;
- width: 100%;
-
- :focus {
- outline: none;
- color: clr(accent);
- }
-}
-
-@supports (-webkit-appearance: none) or (appearance: none) or
- ((-moz-appearance: none) and (mask-type: alpha)) {
- /* Show custom arrow */
- .form-item__dropdown::after {
- display: block;
- }
-
- /* Remove select styling */
- .form-item__select {
- padding-right: 2em; /* Match-01 */
-
- /* inside @supports so that iOS <= 8 display the native arrow */
- background: none; /* Match-04 */
-
- /* inside @supports so that Android <= 4.3 display the native arrow */
- border: 1px solid transparent; /* Match-05 */
- -webkit-appearance: none;
- -moz-appearance: none;
- appearance: none;
-
- &:focus {
- border-color: clr(accent);
- border-radius: 0;
- }
- }
-}
diff --git a/components/01-atoms/forms/select/select.twig b/components/01-atoms/forms/select/select.twig
deleted file mode 100644
index 7a903d8..0000000
--- a/components/01-atoms/forms/select/select.twig
+++ /dev/null
@@ -1,18 +0,0 @@
-{% if not attributes %}
-
-{% endif %}
diff --git a/components/01-atoms/forms/select/select.yml b/components/01-atoms/forms/select/select.yml
deleted file mode 100644
index 597ac2f..0000000
--- a/components/01-atoms/forms/select/select.yml
+++ /dev/null
@@ -1,6 +0,0 @@
-select:
- - title: 'Choose an Option'
- - title: 'Option 1'
- - title: 'Option 2'
- - title: 'Option 3'
- - title: 'Option 4'
diff --git a/components/01-atoms/forms/textfields/_textfields.scss b/components/01-atoms/forms/textfields/_textfields.scss
deleted file mode 100644
index 32c52aa..0000000
--- a/components/01-atoms/forms/textfields/_textfields.scss
+++ /dev/null
@@ -1,47 +0,0 @@
-.form-item {
- color: clr(text);
- margin-bottom: 1em;
- max-width: 32em;
-
- @include clearfix;
-}
-
-.form-item__label {
- display: block;
- font-weight: 600;
-
- @include xs {
- display: block;
- margin-right: 2%;
- padding: 0.6em 0;
- }
-}
-
-.form-item__textfield {
- border: 1px solid clr(highlight-high);
- padding: 0.6em;
- max-width: 100%;
-
- &:focus {
- border-color: clr(accent-high);
- }
-
- &::placeholder {
- color: clr(highlight);
- }
-}
-
-.form-item__description {
- margin-top: 0.3em;
-}
-
-.form-fieldset {
- border: none;
- margin-bottom: 2em;
- padding: 0;
-
- .form-item {
- margin-left: 1em;
- max-width: 31em;
- }
-}
diff --git a/components/01-atoms/forms/textfields/textfields.twig b/components/01-atoms/forms/textfields/textfields.twig
deleted file mode 100644
index b67f470..0000000
--- a/components/01-atoms/forms/textfields/textfields.twig
+++ /dev/null
@@ -1,37 +0,0 @@
-
- Name
-
-
-
-
-
-
-
-
- Number Input
-
-
-
-
- Textarea
-
-
diff --git a/components/01-atoms/images/icons/_icon.twig b/components/01-atoms/images/icons/_icon.twig
deleted file mode 100644
index ee5ee58..0000000
--- a/components/01-atoms/images/icons/_icon.twig
+++ /dev/null
@@ -1,43 +0,0 @@
-{#
-/**
- * Available variables:
- * - icon_base_class - base class name
- * - icon_modifiers - modifiers for icons (array)
- * - icon_blockname - blockname prepended to the base classname
- * - icon_name - the name of the icon
- * - icon_role - a11y role
- * - icon_title - a11y title
- * - icon_desc - a11y description
- */
-#}
-{# If IE 11 support is needed, reference `./svgxuse.min.js` in the theme. #}
-
-{% set icon_base_class = icon_base_class|default('icon') %}
-{# If `directory` is defined, set the path relative for Wordpress.
- # Otherwise, set the path relative to the Component Library. #}
-{% set icon_url = theme ? theme.link ~ '/dist/' %}
-
-
- {% if icon_title %}
- {{ icon_title }}
- {% endif %}
- {% if icon_desc %}
- {{ icon_desc }}
- {% endif %}
-
-
diff --git a/components/01-atoms/images/icons/_icons.scss b/components/01-atoms/images/icons/_icons.scss
deleted file mode 100644
index cb71fb7..0000000
--- a/components/01-atoms/images/icons/_icons.scss
+++ /dev/null
@@ -1,26 +0,0 @@
-// Storybook styling
-.icons-demo {
- display: flex;
-
- .icon {
- height: 100px;
- padding: $space;
- width: 100px;
-
- @media (prefers-color-scheme: dark) {
- fill: clr(accent-high);
- }
- }
-
- pre {
- background-color: clr(muted);
- font-size: 0.8rem;
- margin: 0;
- text-align: center;
- }
-}
-
-.icons-demo__item {
- border: 1px solid clr(muted);
- margin: $space-one-fourth;
-}
diff --git a/components/01-atoms/images/icons/icons.md b/components/01-atoms/images/icons/icons.md
deleted file mode 100644
index 851fb54..0000000
--- a/components/01-atoms/images/icons/icons.md
+++ /dev/null
@@ -1,45 +0,0 @@
----
-title: Icons
----
-
-### How to use icons
-
-We are using an SVG sprite generator (details [here](https://una.im/svg-icons)), which automatically takes individual SVGs from `/images/icons/src` and generates `/dist/img/sprite/sprite.svg`. Simply run `yarn build` to add your individual SVGs to this sprite (this is automatically run when running `yarn develop`).
-
-**Usage**
-
-The SVG component is found here: `/components/01-atoms/images/icons/_icon.twig`. See available variables in that file as well as instructions for Wordpress. Examples of usage below:
-
-Simple: (no BEM renaming)
-
-```
-{% include "@atoms/images/icons/_icon.twig" with {
- icon_name: 'bars',
-} %}
-```
-
-... creates...
-
-```
-
-
-
-```
-
-Complex (BEM classes):
-
-```
-{% include "@atoms/images/icons/_icon.twig" with {
- icon_base_class: 'icon',
- icon_blockname: 'toggle-expand',
- icon_name: 'bars',
-} %}
-```
-
-... creates...
-
-```
-
-
-
-```
diff --git a/components/01-atoms/images/icons/icons.twig b/components/01-atoms/images/icons/icons.twig
deleted file mode 100644
index 46cca23..0000000
--- a/components/01-atoms/images/icons/icons.twig
+++ /dev/null
@@ -1,15 +0,0 @@
-
- {% for item in items %}
-
- {% include "@atoms/images/icons/_icon.twig" with {
- icon_name: item.value,
- icon_role: item.role,
- icon_title: item.title,
- icon_desc: item.desc,
- icon_decorative: item.decorative,
- } %}
-
{{ item.value }}
-
- {% endfor %}
-
-
diff --git a/components/01-atoms/images/icons/svgxuse.min.js b/components/01-atoms/images/icons/svgxuse.min.js
deleted file mode 100644
index ce2099f..0000000
--- a/components/01-atoms/images/icons/svgxuse.min.js
+++ /dev/null
@@ -1,12 +0,0 @@
-/*!
- * @copyright Copyright (c) 2017 IcoMoon.io
- * @license Licensed under MIT license
- * See https://github.com/Keyamoon/svgxuse
- * @version 1.2.6
- */
-(function(){if("undefined"!==typeof window&&window.addEventListener){var e=Object.create(null),l,d=function(){clearTimeout(l);l=setTimeout(n,100)},m=function(){},t=function(){window.addEventListener("resize",d,!1);window.addEventListener("orientationchange",d,!1);if(window.MutationObserver){var k=new MutationObserver(d);k.observe(document.documentElement,{childList:!0,subtree:!0,attributes:!0});m=function(){try{k.disconnect(),window.removeEventListener("resize",d,!1),window.removeEventListener("orientationchange",
-d,!1)}catch(v){}}}else document.documentElement.addEventListener("DOMSubtreeModified",d,!1),m=function(){document.documentElement.removeEventListener("DOMSubtreeModified",d,!1);window.removeEventListener("resize",d,!1);window.removeEventListener("orientationchange",d,!1)}},u=function(k){function e(a){if(void 0!==a.protocol)var c=a;else c=document.createElement("a"),c.href=a;return c.protocol.replace(/:/g,"")+c.host}if(window.XMLHttpRequest){var d=new XMLHttpRequest;var m=e(location);k=e(k);d=void 0===
-d.withCredentials&&""!==k&&k!==m?XDomainRequest||void 0:XMLHttpRequest}return d};var n=function(){function d(){--q;0===q&&(m(),t())}function l(a){return function(){!0!==e[a.base]&&(a.useEl.setAttributeNS("http://www.w3.org/1999/xlink","xlink:href","#"+a.hash),a.useEl.hasAttribute("href")&&a.useEl.setAttribute("href","#"+a.hash))}}function p(a){return function(){var c=document.body,b=document.createElement("x");a.onload=null;b.innerHTML=a.responseText;if(b=b.getElementsByTagName("svg")[0])b.setAttribute("aria-hidden",
-"true"),b.style.position="absolute",b.style.width=0,b.style.height=0,b.style.overflow="hidden",c.insertBefore(b,c.firstChild);d()}}function n(a){return function(){a.onerror=null;a.ontimeout=null;d()}}var a,c,q=0;m();var f=document.getElementsByTagName("use");for(c=0;c
diff --git a/components/01-atoms/images/image/_picture.twig b/components/01-atoms/images/image/_picture.twig
deleted file mode 100644
index 22646ca..0000000
--- a/components/01-atoms/images/image/_picture.twig
+++ /dev/null
@@ -1,21 +0,0 @@
-{% set picture_base_class = picture_base_class|default('picture') %}
-
-
- {% if sources %}
- {#
- Internet Explorer 9 doesn't recognise source elements that are wrapped in
- picture tags. See http://scottjehl.github.io/picturefill/#ie9
- #}
-
- {% for source_attributes in sources %}
-
- {% endfor %}
-
- {% endif %}
- {# The controlling image, with the fallback image in srcset. #}
- {% include "@atoms/images/image/_image.twig" with {
- image_blockname: picture_image_blockname|default(picture_blockname),
- } %}
-
diff --git a/components/01-atoms/images/image/figure.twig b/components/01-atoms/images/image/figure.twig
deleted file mode 100644
index afea978..0000000
--- a/components/01-atoms/images/image/figure.twig
+++ /dev/null
@@ -1,24 +0,0 @@
-{% set image_figure_base_class = image_figure_base_class|default('figure') %}
-{% set image_link_base_class = image_link_base_class|default('link') %}
-
-
- {% if image_url %}
-
- {% endif %}
- {% block figure_content %}
- {% include "@atoms/images/image/responsive-image.twig" with {
- responsive_image_blockname: responsive_image_blockname|default(image_figure_base_class),
- } %}
- {% endblock %}
- {% if image_url %}
-
- {% endif %}
-
- {% if image_caption %}
-
- {{ image_caption }}
-
- {% endif %}
-
diff --git a/components/01-atoms/images/image/figure.yml b/components/01-atoms/images/image/figure.yml
deleted file mode 100644
index 8c808b7..0000000
--- a/components/01-atoms/images/image/figure.yml
+++ /dev/null
@@ -1,5 +0,0 @@
-output_image_tag: true
-image_url: '#'
-image_src: 'https://placeimg.com/1200/200/tech'
-image_alt: 'This is the alt text'
-image_caption: 'This is an image caption.'
diff --git a/components/01-atoms/images/image/image.yml b/components/01-atoms/images/image/image.yml
deleted file mode 100644
index 6cd49c8..0000000
--- a/components/01-atoms/images/image/image.yml
+++ /dev/null
@@ -1,5 +0,0 @@
-output_image_tag: true
-image_srcset: 'https://placeimg.com/320/180/any 320w, https://placeimg.com/480/270/any 480w, https://placeimg.com/640/360/any 640w, https://placeimg.com/800/450/any 800w, https://placeimg.com/960/540/any 960w, https://placeimg.com/1120/630/any 1120w, https://placeimg.com/1280/720/any 1280w, https://placeimg.com/1440/810/any 1440w, https://placeimg.com/1600/900/any 1600w, https://placeimg.com/1760/990/any 1760w, https://placeimg.com/1920/1080/any 1920w, https://placeimg.com/2080/1170/any 2080w, https://placeimg.com/2240/1260/any 2240w'
-image_sizes: '100vw'
-image_src: 'https://placeimg.com/320/180/any'
-image_alt: 'A 16 by 9 image'
diff --git a/components/01-atoms/images/image/responsive-image.twig b/components/01-atoms/images/image/responsive-image.twig
deleted file mode 100644
index 99cb3e7..0000000
--- a/components/01-atoms/images/image/responsive-image.twig
+++ /dev/null
@@ -1,32 +0,0 @@
-{#
- # Available variables:
- # - sources: The attributes of the tags for this tag.
- # - img_element: The controlling image, with the fallback image in srcset.
- # - output_image_tag: Whether or not to output an tag instead of a tag.
- #}
-
-{% set responsive_image_base_class = responsive_image_base_class|default('image') %}
-
-{% if output_image_tag %}
- {% include "@atoms/images/image/_image.twig" with {
- image_base_class: responsive_image_base_class,
- image_modifiers: responsive_image_modifiers,
- image_blockname: responsive_image_blockname,
- image_srcset: image_srcset|default(img_element['#attributes'].srcset),
- image_sizes: image_sizes|default(img_element['#attributes'].sizes),
- image_src: image_src|default(img_element['#uri']),
- image_alt: image_alt|default(img_element['#alt']),
- image_title: image_title|default(img_element['#title']),
- } %}
-{% else %}
- {% include "@atoms/images/image/_picture.twig" with {
- picture_base_class: responsive_image_base_class,
- picture_modifiers: responsive_image_modifiers,
- picture_blockname: responsive_image_blockname,
- image_srcset: image_srcset|default(img_element['#attributes'].srcset),
- image_sizes: image_sizes|default(img_element['#attributes'].sizes),
- image_src: image_src|default(img_element['#uri']),
- image_alt: image_alt|default(img_element['#alt']),
- image_title: image_title|default(img_element['#title']),
- } %}
-{% endif %}
diff --git a/components/01-atoms/images/images.stories.js b/components/01-atoms/images/images.stories.js
deleted file mode 100644
index 66290bb..0000000
--- a/components/01-atoms/images/images.stories.js
+++ /dev/null
@@ -1,34 +0,0 @@
-import React from 'react';
-
-import image from './image/responsive-image.twig';
-import figure from './image/figure.twig';
-import iconTwig from './icons/icons.twig';
-
-import imageData from './image/image.yml';
-import figureData from './image/figure.yml';
-
-const svgIcons = require.context('../../../images/icons/', true, /\.svg$/);
-
-/**
- * Storybook Definition.
- */
-export default { title: 'Atoms/Images' };
-
-export const images = () => (
-
-);
-export const figures = () => (
-
-);
-
-const items = [];
-svgIcons.keys().forEach((key) => {
- const iconName = svgIcons(key).split('static/media/').pop().split('.')[0];
- const icon = {};
- icon.value = iconName;
- items.push(icon);
-});
-
-export const icons = () => (
-
-);
diff --git a/components/01-atoms/links/link/_link.scss b/components/01-atoms/links/link/_link.scss
deleted file mode 100644
index 1637caf..0000000
--- a/components/01-atoms/links/link/_link.scss
+++ /dev/null
@@ -1,11 +0,0 @@
-@mixin link {
- color: clr(accent);
-
- &:hover {
- color: clr(accent-high);
- }
-}
-
-.link {
- @include link;
-}
diff --git a/components/01-atoms/links/link/link.twig b/components/01-atoms/links/link/link.twig
deleted file mode 100644
index e181bf6..0000000
--- a/components/01-atoms/links/link/link.twig
+++ /dev/null
@@ -1,28 +0,0 @@
-{#
-/**
- * Available variables:
- * - link_content - the content of the link (typically text)
- * - link_url - the url this link should poing to
- * - link_attributes - array of attribute,value pairs
- * - link_base_class - the base class name
- * - link_modifiers - array of modifiers to add to the base classname
- * - link_blockname - blockname prepended to the base classname
- *
- * Available blocks:
- * - link_content - used to replace the content of the link
- * Example: to insert the image component
- */
-#}
-{% set link_base_class = link_base_class|default('link') %}
-
-
- {% block link_content %}
- {{ link_content }}
- {% endblock %}
-
diff --git a/components/01-atoms/links/link/link.yml b/components/01-atoms/links/link/link.yml
deleted file mode 100644
index f7c96ef..0000000
--- a/components/01-atoms/links/link/link.yml
+++ /dev/null
@@ -1,4 +0,0 @@
-link_url: 'https://github.com/emulsify-ds/emulsify-design-system'
-link_content: 'This is my link text'
-link_attributes:
- 'target': '_blank'
diff --git a/components/01-atoms/links/link/links.stories.js b/components/01-atoms/links/link/links.stories.js
deleted file mode 100644
index 3bd99f8..0000000
--- a/components/01-atoms/links/link/links.stories.js
+++ /dev/null
@@ -1,14 +0,0 @@
-import React from 'react';
-
-import link from './link.twig';
-
-import linkData from './link.yml';
-
-/**
- * Storybook Definition.
- */
-export default { title: 'Atoms/Links' };
-
-export const links = () => (
-
-);
diff --git a/components/01-atoms/lists/_list-item-definition.twig b/components/01-atoms/lists/_list-item-definition.twig
deleted file mode 100644
index 6126376..0000000
--- a/components/01-atoms/lists/_list-item-definition.twig
+++ /dev/null
@@ -1,5 +0,0 @@
-{% set dl_term_base_class = dl_term_base_class|default('dl-term') %}
-{% set dl_def_base_class = dl_def_base_class|default('dl-def') %}
-
-{{ dl_term }}
-{{ dl_def }}
diff --git a/components/01-atoms/lists/_list-item.twig b/components/01-atoms/lists/_list-item.twig
deleted file mode 100644
index b13db98..0000000
--- a/components/01-atoms/lists/_list-item.twig
+++ /dev/null
@@ -1,22 +0,0 @@
-{#
-/**
- * Available variables:
- * - li_base_class - the base classname
- * - li_modifiers - array of modifiers to add to the base classname
- * - li_blockname - blockname prepended to the base classname
- * - list_item_label - (optional) a label before the list item itself
- * - list_item_content - the content of the list_item (typically text)
- *
- * Available blocks:
- * - list_item_content - used to replace the content of the list_item with something other than text
- * for example: to insert the image and/or link components
- */
-#}
-{% set li_base_class = li_base_class|default('list-item') %}
-
-
- {% block list_item_content %}
- {% if list_item_label %}{{ list_item_label }} {% endif %}
- {{ list_item_content }}
- {% endblock %}
-
diff --git a/components/01-atoms/lists/_lists.scss b/components/01-atoms/lists/_lists.scss
deleted file mode 100644
index f5a22c2..0000000
--- a/components/01-atoms/lists/_lists.scss
+++ /dev/null
@@ -1,32 +0,0 @@
-/* Mixin - list-reset
- * Reset list item defaults when no margin, padding, list styles needed
-*/
-
-ul,
-ol {
- padding-left: 1em;
-}
-
-ul {
- list-style-type: disc;
-}
-
-ol {
- list-style-type: decimal;
-}
-
-.list-item {
- margin-bottom: 0.2em;
- padding-left: 1em;
-}
-
-/* TODO: BEM selectors should be applied to wysiwyg-created content */
-
-.text-long {
- ol,
- ul {
- li {
- @extend .list-item;
- }
- }
-}
diff --git a/components/01-atoms/lists/dl.twig b/components/01-atoms/lists/dl.twig
deleted file mode 100644
index 2cd1a8f..0000000
--- a/components/01-atoms/lists/dl.twig
+++ /dev/null
@@ -1,28 +0,0 @@
-{#
-/**
- * Available variables:
- * - dl_base_class - the base classname
- * - dl_modifiers - array of modifiers to add to the base classname
- * - dl_blockname - blockname prepended to the base classname
- *
- * - listItems - TBD
- */
-#}
-{% set dl_base_class = dl_base_class|default('dl') %}
-
-
- {% block list_content %}
- {% for dl_item in dl_items %}
- {% include "@atoms/lists/_list-item-definition.twig" with {
- dl_term: dl_item.dl_term,
- dl_def: dl_item.dl_def,
- dl_term_base_class: dl_item.dl_term_base_class,
- dl_def_base_class: dl_item.dl_def_base_class,
- dl_term_modifiers: dl_item.dl_term_modifiers,
- dl_def_modifiers: dl_item.dl_def_modifiers,
- dl_term_blockname: dl_item.dl_term_blockname,
- dl_def_blockname: dl_item.dl_def_blockname,
- } %}
- {% endfor %}
- {% endblock %}
-
diff --git a/components/01-atoms/lists/dl.yml b/components/01-atoms/lists/dl.yml
deleted file mode 100644
index 1eac9e6..0000000
--- a/components/01-atoms/lists/dl.yml
+++ /dev/null
@@ -1,9 +0,0 @@
-dl_items:
- - dl_term: 'Definition List'
- dl_def: 'A number of connected items or names written or printed consecutively, typically one below the other.'
- - dl_term: 'This is a term.'
- dl_def: 'This is the definition of that term, which both live in a dl.'
- - dl_term: 'Here is another term.'
- dl_def: 'And it gets a definition too, which is this line.'
- - dl_term: 'Here is one last term.'
- dl_def: 'With the final definition.'
diff --git a/components/01-atoms/lists/lists.stories.js b/components/01-atoms/lists/lists.stories.js
deleted file mode 100644
index 8399dc2..0000000
--- a/components/01-atoms/lists/lists.stories.js
+++ /dev/null
@@ -1,24 +0,0 @@
-import React from 'react';
-
-import dl from './dl.twig';
-import ul from './ul.twig';
-import ol from './ol.twig';
-
-import dlData from './dl.yml';
-import ulData from './ul.yml';
-import olData from './ol.yml';
-
-/**
- * Storybook Definition.
- */
-export default { title: 'Atoms/Lists' };
-
-export const definitionList = () => (
-
-);
-export const unorderedList = () => (
-
-);
-export const orderedList = () => (
-
-);
diff --git a/components/01-atoms/lists/ol.twig b/components/01-atoms/lists/ol.twig
deleted file mode 100644
index 7fd24aa..0000000
--- a/components/01-atoms/lists/ol.twig
+++ /dev/null
@@ -1,26 +0,0 @@
-{#
-/**
- * Available variables:
- * - ol_base_class - the base classname
- * - ol_modifiers - array of modifiers to add to the base classname
- * - ol_blockname - blockname prepended to the base classname
- *
- * - listItems - TBD
- */
-#}
-{% set ol_base_class = ol_base_class|default('ol') %}
-
-
- {% block list_content %}
- {% for ol_item in ol_items %}
- {% include "@atoms/lists/_list-item.twig" with {
- list_item_label: ol_item.label,
- list_item_content: ol_item.content,
- li_base_class: ol_item.li_base_class,
- li_base_class: ol_item.li_base_class,
- li_modifiers: ol_item.li_modifiers,
- li_blockname: ol_item.li_blockname,
- } %}
- {% endfor %}
- {% endblock %}
-
diff --git a/components/01-atoms/lists/ol.yml b/components/01-atoms/lists/ol.yml
deleted file mode 100644
index b9bd06d..0000000
--- a/components/01-atoms/lists/ol.yml
+++ /dev/null
@@ -1,6 +0,0 @@
-ol_items:
- - content: 'This is the first item in the ordered list.'
- - content: 'And here is the item that goes with the label.'
- label: 'This is the optional label'
- - content: "Here's the third item."
- - content: "And here's the last item."
diff --git a/components/01-atoms/lists/ul.twig b/components/01-atoms/lists/ul.twig
deleted file mode 100644
index af61047..0000000
--- a/components/01-atoms/lists/ul.twig
+++ /dev/null
@@ -1,26 +0,0 @@
-{#
-/**
- * Available variables:
- * - ul_base_class - the base classname
- * - ul_modifiers - array of modifiers to add to the base classname
- * - ul_blockname - blockname prepended to the base classname
- *
- * - listItems - TBD
- */
-#}
-{% set ul_base_class = ul_base_class|default('ul') %}
-
-
- {% block list_content %}
- {% for ul_item in ul_items %}
- {% include "@atoms/lists/_list-item.twig" with {
- list_item_label: ul_item.label,
- list_item_content: ul_item.content,
- li_base_class: ul_item.li_base_class,
- li_base_class: ul_item.li_base_class,
- li_modifiers: ul_item.li_modifiers,
- li_blockname: ul_item.li_blockname,
- } %}
- {% endfor %}
- {% endblock %}
-
diff --git a/components/01-atoms/lists/ul.yml b/components/01-atoms/lists/ul.yml
deleted file mode 100644
index 7604fec..0000000
--- a/components/01-atoms/lists/ul.yml
+++ /dev/null
@@ -1,6 +0,0 @@
-ul_items:
- - content: 'This is the first item in the unordered list.'
- - content: 'And here is the item that goes with the label.'
- label: 'This is the optional label'
- - content: "Here's the third item."
- - content: "And here's the last item."
diff --git a/components/01-atoms/tables/_tables.scss b/components/01-atoms/tables/_tables.scss
deleted file mode 100644
index 615d19c..0000000
--- a/components/01-atoms/tables/_tables.scss
+++ /dev/null
@@ -1,94 +0,0 @@
-.table {
- border: 1px solid clr(highlight-high);
- border-radius: 4px;
- border-spacing: 0;
- background-color: clr(muted);
- margin: 1em 0;
- width: 100%;
-}
-
-.table__heading-cell,
-.table__cell {
- border: none;
- border-bottom: 1px solid clr(highlight-high);
- border-right: 1px solid clr(highlight-high);
- padding: 1em;
-
- &:last-child {
- border-right: none;
- }
-}
-
-.table__heading-cell {
- color: clr(accent-high);
- font-weight: 700;
- padding: 1.2em;
- text-align: left;
-}
-
-.table__row {
- &:nth-child(odd) {
- background-color: clr(background);
- }
-
- /* Top Row - Non-BEM but always contained */
- &:first-child {
- th:first-child,
- td:first-child {
- border-radius: 5px 0 0;
- }
-
- th:last-child,
- td:last-child {
- border-radius: 0 5px 0 0;
- }
- }
-
- /* Bottom Row - Non-BEM but always contained */
- &:last-child {
- tbody & {
- th,
- td {
- border-bottom: none;
- }
- }
-
- td:first-child {
- border-radius: 0 0 0 5px;
- }
-
- td:last-child {
- border-radius: 0 0 5px;
- }
- }
-}
-
-.table__footer-cell,
-.table__footer-cell:first-child {
- border-bottom: none;
- border-top: 1px solid clr(highlight-high);
-}
-
-/* TODO: BEM selectors should be applied to wysiwyg-created content */
-
-.text-long {
- table {
- @extend .table;
- }
-
- th {
- @extend .table__heading-cell;
- }
-
- td {
- @extend .table__cell;
- }
-
- tr {
- @extend .table__row;
- }
-
- tfoot th {
- @extend .table__footer-cell;
- }
-}
diff --git a/components/01-atoms/tables/tables.stories.js b/components/01-atoms/tables/tables.stories.js
deleted file mode 100644
index e965ae8..0000000
--- a/components/01-atoms/tables/tables.stories.js
+++ /dev/null
@@ -1,12 +0,0 @@
-import React from 'react';
-
-import tables from './tables.twig';
-
-/**
- * Storybook Definition.
- */
-export default { title: 'Atoms/Tables' };
-
-export const table = () => (
-
-);
diff --git a/components/01-atoms/tables/tables.twig b/components/01-atoms/tables/tables.twig
deleted file mode 100644
index 3142fb6..0000000
--- a/components/01-atoms/tables/tables.twig
+++ /dev/null
@@ -1,40 +0,0 @@
-{#
-/**
- * This is strictly an exmaple file to demonstrate markup in Storybook.
- * It is not intended to be included in any other component.
- */
-#}
-
-
-
- This is a heading
-
-
-
-
- Row 1
- Information about something
- And this is important
-
-
- Row 2
- Information about something
- And this is important
-
-
- Row 3
- Information about something
- And this is important
-
-
- Row 4
- Information about something
- And this is important
-
-
-
-
-
-
-
-
diff --git a/components/01-atoms/text/headings/_heading.twig b/components/01-atoms/text/headings/_heading.twig
deleted file mode 100644
index 4e744d1..0000000
--- a/components/01-atoms/text/headings/_heading.twig
+++ /dev/null
@@ -1,34 +0,0 @@
-{#
-/**
- * Available variables:
- * - heading_level - the header level 1-6 (produces h1, h2, etc.)
- *
- * - heading_base_class - the base class
- * - heading_modifiers - array of modifiers to add to the base classname
- * - heading_blockname - blockname prepended to the base classname
- *
- * - heading - the content of the heading (typically text)
- *
- * - heading_url - (optional) the url the heading should poing to
- * - heading_link_attributes - key/value attributes to pass to link
- * - heading_link_base_class - override the link base class
- * - heading_link_modifiers - override the link modifiers
- * - heading_link_blockname - override the link block name (defaults to heading_base_class)
- */
-#}
-{% set heading_base_class = heading_base_class|default('h' ~ heading_level) %}
-
-
- {% if heading_url %}
- {% include "@atoms/links/link/link.twig" with {
- link_content: heading,
- link_url: heading_url,
- link_attributes: heading_link_attributes,
- link_base_class: heading_link_base_class,
- link_modifiers: heading_link_modifiers,
- link_blockname: heading_link_blockname|default(heading_base_class),
- } %}
- {% else %}
- {{ heading }}
- {% endif %}
-
diff --git a/components/01-atoms/text/headings/_headings.scss b/components/01-atoms/text/headings/_headings.scss
deleted file mode 100644
index 4108c3b..0000000
--- a/components/01-atoms/text/headings/_headings.scss
+++ /dev/null
@@ -1,195 +0,0 @@
-/*
- * Generic header styles:
- * All arguments are optional. If not defined, the defaults below will be used
-*/
-
-$color-heading: clr(accent);
-
-@mixin heading-xl(
- $font-family: $font-heading,
- $font-size: 2rem,
- $line-height: 1.4,
- $color: $color-heading,
- $color-link: inherit,
- $color-link-hover: inherit,
- $font-weight: 700,
- $margin: 0 0 0.5em
-) {
- color: $color;
- font-family: $font-family;
- font-weight: $font-weight;
- font-style: normal;
- font-size: $font-size;
- line-height: $line-height;
- margin: $margin;
- width: auto;
-
- &__link {
- color: $color-link;
-
- &:hover {
- color: $color-link-hover;
- }
- }
-}
-
-@mixin heading-large(
- $font-family: $font-heading,
- $font-size: 1.8rem,
- $line-height: 1.4,
- $color: $color-heading,
- $color-link: inherit,
- $color-link-hover: inherit,
- $font-weight: 700,
- $margin: 0 0 0.5em
-) {
- color: $color;
- font-family: $font-family;
- font-weight: $font-weight;
- font-style: normal;
- font-size: $font-size;
- line-height: $line-height;
- margin: $margin;
- width: auto;
-
- &__link {
- color: $color-link;
-
- &:hover {
- color: $color-link-hover;
- }
- }
-}
-
-@mixin heading-medium(
- $font-family: $font-heading,
- $font-size: 1.4rem,
- $line-height: 1.6,
- $color: $color-heading,
- $color-link: inherit,
- $color-link-hover: inherit,
- $font-weight: 700,
- $margin: 0 0 0.5em
-) {
- color: $color;
- font-family: $font-family;
- font-weight: $font-weight;
- font-style: normal;
- font-size: $font-size;
- line-height: $line-height;
- margin: $margin;
- width: auto;
-
- &__link {
- color: $color-link;
-
- &:hover {
- color: $color-link-hover;
- }
- }
-}
-
-@mixin heading-small(
- $font-family: $font-heading,
- $font-size: 1.2rem,
- $line-height: 1.6,
- $color: $color-heading,
- $color-link: inherit,
- $color-link-hover: inherit,
- $font-weight: 600,
- $margin: 0 0 0.5em
-) {
- color: $color;
- font-family: $font-family;
- font-weight: $font-weight;
- font-style: normal;
- font-size: $font-size;
- line-height: $line-height;
- margin: $margin;
- width: auto;
-
- &__link {
- color: $color-link;
-
- &:hover {
- color: $color-link-hover;
- }
- }
-}
-
-@mixin heading-xs(
- $font-family: $font-heading,
- $font-size: 1.1rem,
- $line-height: 2,
- $color: $color-heading,
- $color-link: inherit,
- $color-link-hover: inherit,
- $font-weight: 600,
- $margin: 0 0 0.5em
-) {
- color: $color;
- font-family: $font-family;
- font-weight: $font-weight;
- font-style: normal;
- font-size: $font-size;
- line-height: $line-height;
- margin: $margin;
- width: auto;
-
- &__link {
- color: $color-link;
-
- &:hover {
- color: $color-link-hover;
- }
- }
-}
-
-.h1 {
- @include heading-xl;
-}
-
-.h2 {
- @include heading-large;
-}
-
-.h3 {
- @include heading-medium;
-}
-
-.h4 {
- @include heading-small;
-}
-
-.h5,
-.h6 {
- @include heading-xs;
-}
-
-/* TODO: BEM selectors should be applied to wysiwyg-created content */
-
-.text-long {
- h1 {
- @extend .h1;
- }
-
- h2 {
- @extend .h2;
- }
-
- h3 {
- @extend .h3;
- }
-
- h4 {
- @extend .h4;
- }
-
- h5 {
- @extend .h5;
- }
-
- h6 {
- @extend .h6;
- }
-}
diff --git a/components/01-atoms/text/headings/headings.twig b/components/01-atoms/text/headings/headings.twig
deleted file mode 100644
index 0808151..0000000
--- a/components/01-atoms/text/headings/headings.twig
+++ /dev/null
@@ -1,11 +0,0 @@
-
-{% if meta %}
- {{ meta.description }}
-{% endif %}
diff --git a/components/01-atoms/text/text.stories.js b/components/01-atoms/text/text.stories.js
deleted file mode 100644
index 8f0391b..0000000
--- a/components/01-atoms/text/text.stories.js
+++ /dev/null
@@ -1,26 +0,0 @@
-import React from 'react';
-
-import headings from './headings/headings.twig';
-import blockquote from './text/02-blockquote.twig';
-import pre from './text/05-pre.twig';
-import paragraph from './text/03-inline-elements.twig';
-
-import blockquoteData from './text/blockquote.yml';
-
-/**
- * Storybook Definition.
- */
-export default { title: 'Atoms/Text' };
-
-export const headingsExamples = () => (
-
-);
-export const blockquoteExample = () => (
-
-);
-export const preformatted = () => (
-
-);
-export const random = () => (
-
-);
diff --git a/components/01-atoms/text/text/01-paragraph.twig b/components/01-atoms/text/text/01-paragraph.twig
deleted file mode 100644
index c2422c0..0000000
--- a/components/01-atoms/text/text/01-paragraph.twig
+++ /dev/null
@@ -1,20 +0,0 @@
-{#
-/**
- * Available variables:
- * - paragraph_base_class - the base classname
- * - paragraph_modifiers - array of modifiers to add to the base classname
- * - paragraph_blockname - blockname prepended to the base classname
- * - paragraph_content - the content of the paragraph (typically text)
- *
- * Available blocks:
- * - paragraph_content - used to replace the content of the paragraph with something other than plain text
- * for example: A formatted text field in Wordpress
- */
-#}
-{% set paragraph_base_class = paragraph_base_class|default('paragraph') %}
-
-
- {% block paragraph_content %}
- {{ paragraph_content }}
- {% endblock %}
-
diff --git a/components/01-atoms/text/text/02-blockquote.twig b/components/01-atoms/text/text/02-blockquote.twig
deleted file mode 100644
index 4939a4b..0000000
--- a/components/01-atoms/text/text/02-blockquote.twig
+++ /dev/null
@@ -1,23 +0,0 @@
-{#
-/**
- * Available variables:
- * - blockquote_base_class - the base classname
- * - blockquote_modifiers - array of modifiers to add to the base classname
- * - blockquote_blockname - blockname prepended to the base classname
- * - blockquote_url - the url this blockquote should point to
- * - blockquote_content - the content of the blockquote (typically text)
- *
- * Available blocks:
- * - blockquote_content - used to replace the content of the blockquote with something other than text
- * for example: A formatted text field in Wordpress
- */
-#}
-{% set blockquote_base_class = blockquote_base_class|default('blockquote') %}
-
-
- {% block blockquote_content %}
- {{ blockquote_content }}
- {% endblock %}
-
diff --git a/components/01-atoms/text/text/03-inline-elements.twig b/components/01-atoms/text/text/03-inline-elements.twig
deleted file mode 100644
index 8c10017..0000000
--- a/components/01-atoms/text/text/03-inline-elements.twig
+++ /dev/null
@@ -1,45 +0,0 @@
-{#
-/**
- * This is strictly an exmaple file to demonstrate markup in Storybook.
- * It is not intended to be included in any other component.
- */
-#}
-
-
Strong is used to indicate strong importance
-
-
This text has added emphasis
-
-
The b element is stylistically different text from normal text, without any special importance
-
-
The i element is text that is set off from the normal text
-
-
The u element is text with an unarticulated, though explicitly rendered, non-textual annotation
-
-
This text is deleted and This text is inserted
-
-
This text has a strikethrough
-
-
Superscript®
-
-
Subscript for things like H2 O
-
-
This text is small for fine print, etc.
-
-
Abbreviation: HTML
-
-
Keybord input: Cmd
-
-
This text is a short inline quotation
-
-
This is a citation
-
-
The dfn element indicates a definition.
-
-
The mark element indicates a highlight
-
-
This is what inline code looks like.
-
-
This is sample output from a computer program
-
-
The variarble element , such as x = y
-
diff --git a/components/01-atoms/text/text/05-pre.twig b/components/01-atoms/text/text/05-pre.twig
deleted file mode 100644
index cdebd17..0000000
--- a/components/01-atoms/text/text/05-pre.twig
+++ /dev/null
@@ -1,15 +0,0 @@
-{#
-/**
- * This is strictly an exmaple file to demonstrate markup in Storybook.
- * It is not intended to be included in any other component.
- */
-#}
-
-P R E F O R M A T T E D T E X T
-! " # $ % & ' ( ) * + , - . /
-0 1 2 3 4 5 6 7 8 9 : ; < = > ?
-@ A B C D E F G H I J K L M N O
-P Q R S T U V W X Y Z [ \ ] ^ _
-` a b c d e f g h i j k l m n o
-p q r s t u v w x y z { | } ~
-
diff --git a/components/01-atoms/text/text/06-hr.twig b/components/01-atoms/text/text/06-hr.twig
deleted file mode 100644
index 50e14d6..0000000
--- a/components/01-atoms/text/text/06-hr.twig
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/components/01-atoms/text/text/_text.scss b/components/01-atoms/text/text/_text.scss
deleted file mode 100644
index a4bdda7..0000000
--- a/components/01-atoms/text/text/_text.scss
+++ /dev/null
@@ -1,39 +0,0 @@
-/* Create a mixin for paragraph styles that can be implemented
- * in components with other classnames.
-*/
-@mixin paragraph($margin: 0 0 1em) {
- margin: $margin;
-}
-
-.paragraph {
- @include paragraph;
-}
-
-.blockquote {
- font-style: italic;
- border-left: solid 3px clr(accent);
- margin-left: 1em;
- padding-left: 1em;
-}
-
-.hr {
- border-style: solid;
- border-width: 1px 0 0;
- color: currentColor;
-}
-
-/* TODO: BEM selectors should be applied to wysiwyg-created content */
-
-.text-long {
- p {
- @extend .paragraph;
- }
-
- blockquote {
- @extend .blockquote;
- }
-
- hr {
- @extend .hr;
- }
-}
diff --git a/components/01-atoms/text/text/blockquote.yml b/components/01-atoms/text/text/blockquote.yml
deleted file mode 100644
index 4fcc874..0000000
--- a/components/01-atoms/text/text/blockquote.yml
+++ /dev/null
@@ -1 +0,0 @@
-blockquote_content: 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet.'
diff --git a/components/01-atoms/video/_video.scss b/components/01-atoms/video/_video.scss
deleted file mode 100644
index 11da429..0000000
--- a/components/01-atoms/video/_video.scss
+++ /dev/null
@@ -1,20 +0,0 @@
-/* Responsive Video using CSS only */
-.video {
- height: 0;
- overflow: hidden;
- padding-top: 35px;
- padding-bottom: 56.25%; /* 56.25% = 16x9 */
- position: relative;
-
- iframe {
- height: 100%;
- left: 0;
- position: absolute;
- top: 0;
- width: 100%;
- }
-}
-
-.video--full {
- padding-bottom: 75%; /* 75% = 4x3 */
-}
diff --git a/components/01-atoms/video/video-full.yml b/components/01-atoms/video/video-full.yml
deleted file mode 100644
index 183f2d9..0000000
--- a/components/01-atoms/video/video-full.yml
+++ /dev/null
@@ -1,3 +0,0 @@
-video_content: "VIDEO "
-video_modifiers:
- - 'full'
diff --git a/components/01-atoms/video/video.twig b/components/01-atoms/video/video.twig
deleted file mode 100644
index 1c46b42..0000000
--- a/components/01-atoms/video/video.twig
+++ /dev/null
@@ -1,23 +0,0 @@
-{#
-/**
- * Available variables:
- * - video_base_class - the base classname
- * - video_modifiers - array of modifiers to add to the base classname
- * by default, videos are expected to be 16x9, include the modifier "full" to support a 4x3 video
- * - video_blockname - blockname prepended to the base classname
- * - video_content - the content of the video (typically an iframe)
- * the "|raw" filter is applied so that the iframe is rendered instead
- * of simply passing the iframe as plain text.
- *
- * Available blocks:
- * - video_content - used to replace the content of the video with something other than the typical iframe
- * for example: to insert an html5 video component
- */
-#}
-{% set video_base_class = video_base_class|default('video') %}
-
-
- {% block video_content %}
- {{ video_content|raw }}
- {% endblock %}
-
diff --git a/components/01-atoms/video/video.yml b/components/01-atoms/video/video.yml
deleted file mode 100644
index bfd7474..0000000
--- a/components/01-atoms/video/video.yml
+++ /dev/null
@@ -1 +0,0 @@
-video_content: "VIDEO "
diff --git a/components/01-atoms/video/videos.stories.js b/components/01-atoms/video/videos.stories.js
deleted file mode 100644
index 52c2a16..0000000
--- a/components/01-atoms/video/videos.stories.js
+++ /dev/null
@@ -1,18 +0,0 @@
-import React from 'react';
-
-import video from './video.twig';
-
-import videoData from './video.yml';
-import videoFullData from './video-full.yml';
-
-/**
- * Storybook Definition.
- */
-export default { title: 'Atoms/Videos' };
-
-export const wide = () => (
-
-);
-export const full = () => (
-
-);
diff --git a/components/02-molecules/card/_card.scss b/components/02-molecules/card/_card.scss
deleted file mode 100644
index 640b996..0000000
--- a/components/02-molecules/card/_card.scss
+++ /dev/null
@@ -1,35 +0,0 @@
-.card__heading {
- @include heading-large($font-size: 1.3rem);
- @include no-bottom;
-
- &-link {
- @include link;
- }
-}
-
-.card__subheading {
- @include heading-medium($font-size: 1rem);
- @include no-bottom;
-
- color: clr(text);
-}
-
-.card__body {
- margin: 0.7em 0 1em;
-}
-
-.card__link {
- @include link;
-}
-
-.card__button {
- @include button-base;
- @include button-color-primary;
- @include button-medium;
-}
-
-/* Variations */
-.card--bg {
- background-color: clr(highlight-high);
- padding: 1em;
-}
diff --git a/components/02-molecules/card/card-bg.yml b/components/02-molecules/card/card-bg.yml
deleted file mode 100644
index b31cda2..0000000
--- a/components/02-molecules/card/card-bg.yml
+++ /dev/null
@@ -1,2 +0,0 @@
-card__modifiers:
- - 'bg'
diff --git a/components/02-molecules/card/card.twig b/components/02-molecules/card/card.twig
deleted file mode 100644
index 9c98ff7..0000000
--- a/components/02-molecules/card/card.twig
+++ /dev/null
@@ -1,115 +0,0 @@
-{#
- # Available variables:
- # - card__base_class - base classname of the wrapper.
- # - card__modifiers - array of modifiers to add to the base classname of the wrapper.
- # - card__blockname - blockname prepended to the base classname of the wrapper(s) and each component.
- #
- # - card__image__src - the actual image file location.
- # - card__image__alt - (optional) the alt text for screen readers and when the image cannot load.
- # - card__image__output_image_tag - whether to print the picture element or not.
- #
- # - card__content__base_class - base classname of the copy wrapper - defaults to 'heading'.
- # - card__content__modifiers - array of modifiers to add to the base classname of the copy wrapper.
- # - card__content__blockname - blockname prepended to the base classname of the copy wrapper.
- #
- # - card__heading - the content of the title component.
- # - card__heading__link - (optional) the url the title should link to. Defaults to card__link__url.
- # - card__heading__base_class - base classname of the title component. Defaults to "title".
- # - card__heading__blockname - blockname to add to the base classname of the title - defaults to card__base_class.
- # - card__heading__modifiers - array of modifiers to add to the base classname of the title.
- # - card__heading__link_base_class - base class to add to the title link - defaults to 'title-link'.
- #
- # - card__subheading - the content of the subtitle component.
- # - card__subheading__link - (optional) the url the subtitle should link to.
- # - card__subheading__base_class - base classname of the subtitle component. Defaults to "subtitle".
- # - card__subheading__modifiers - array of modifiers to add to the base classname of the subtitle.
- #
- # - card__body - the content of the body component.
- # - card__body__base_class - base classname of the body component. Defaults to "body".
- # - card__body__modifiers - array of modifiers to add to the base classname of the body.
- #
- # - card__link__text - the content of the link component.
- # - card__link__url - the url the link should link to.
- # - card__link__base_class - base classname of the link component. Defaults to "link".
- # - card__link__blockname - override link blockname. Defaults to card__base_class.
- # - card__link__attributes - array of attribute,value pairs for the link attribute.
- # - card__link__modifiers - array of modifiers to add to the base classname of the link.
- #
- # - card__button__content - the content of the button component.
- # - card__button__url - the url the button should link to.
- # - card__button__base_class - base classname of the button component. Defaults to "button".
- # - card__button__attributes - array of attribute,value pairs for the button attribute.
- # - card__button__modifiers - array of modifiers to add to the base classname of the button.
- #}
-{% set card__base_class = 'card' %}
-
-
- {# Image #}
- {% block card__img %}
- {% if card__image__src %}
- {% include "@atoms/images/image/responsive-image.twig" with {
- image_blockname: card__base_class,
- output_image_tag: card__image__output_image_tag,
- image_src: card__image__src,
- image_alt: card__image__alt,
- } %}
- {% endif %}
- {% endblock %}
- {# Content #}
-
- {# Heading #}
- {% if card__heading %}
- {% include "@atoms/text/headings/_heading.twig" with {
- heading_base_class: card__heading__base_class|default('heading'),
- heading_modifiers: card__heading__modifiers,
- heading_blockname: card__base_class,
- heading_level: 2,
- heading: card__heading,
- heading_url: card__heading__link|default(card__link__url),
- heading_link_base_class: card__heading__link_base_class|default('heading-link'),
- heading_link_blockname: card__base_class,
- } %}
- {% endif %}
- {# Subheading #}
- {% if card__subheading %}
- {% include "@atoms/text/headings/_heading.twig" with {
- heading_base_class: card__subheading__base_class|default('subheading'),
- heading_modifiers: card__subheading__modifiers,
- heading_blockname: card__base_class,
- heading_level: 3,
- heading: card__subheading,
- heading_url: card__subheading__link,
- } %}
- {% endif %}
- {# Body #}
- {% if card__body %}
- {% include "@atoms/text/text/01-paragraph.twig" with {
- paragraph_base_class: card__body__base_class|default('body'),
- paragraph_modifiers: card__body__modifiers,
- paragraph_blockname: card__base_class,
- paragraph_content: card__body,
- } %}
- {% endif %}
- {# Link #}
- {% if card__link__url %}
- {% include "@atoms/links/link/link.twig" with {
- link_base_class: card__link__base_class|default('link'),
- link_blockname: card__base_class,
- link_attributes: card__link__attributes,
- link_content: card__link__text,
- link_url: card__link__url,
- } %}
- {% endif %}
- {# Button #}
- {% if card__button__url %}
- {% include "@atoms/buttons/twig/button.twig" with {
- button_base_class: card__button__base_class|default('button'),
- button_modifiers: card__button__modifiers,
- button_blockname: card__base_class,
- button_attributes: card__button__attributes,
- button_content: card__button__content,
- button_url: card__button__url,
- } %}
- {% endif %}
-
-
diff --git a/components/02-molecules/card/card.yml b/components/02-molecules/card/card.yml
deleted file mode 100644
index 02a5467..0000000
--- a/components/02-molecules/card/card.yml
+++ /dev/null
@@ -1,8 +0,0 @@
-card__image__src: 'https://placeimg.com/1280/720/nature'
-card__image__alt: "This is the card image's alt text"
-card__image__output_image_tag: true
-card__heading: 'This is the card heading'
-card__subheading: 'Curabitur non nulla sit amet nisl tempus convallis quis ac lectus'
-card__body: 'ellentesque in ipsum id orci porta dapibus. Mauris blandit aliquet elit, eget tincidunt nibh pulvinar a. Mauris blandit aliquet elit, eget tincidunt nibh pulvinar a.'
-card__link__text: 'Read more about this card'
-card__link__url: '#'
diff --git a/components/02-molecules/card/cards.stories.js b/components/02-molecules/card/cards.stories.js
deleted file mode 100644
index f517eb6..0000000
--- a/components/02-molecules/card/cards.stories.js
+++ /dev/null
@@ -1,20 +0,0 @@
-import React from 'react';
-
-import card from './card.twig';
-
-import cardData from './card.yml';
-import cardBgData from './card-bg.yml';
-
-/**
- * Storybook Definition.
- */
-export default { title: 'Molecules/Cards' };
-
-export const cardExample = () => (
-
-);
-export const cardWithBackground = () => (
-
-);
diff --git a/components/02-molecules/cta/_cta.scss b/components/02-molecules/cta/_cta.scss
deleted file mode 100644
index 04fe925..0000000
--- a/components/02-molecules/cta/_cta.scss
+++ /dev/null
@@ -1,6 +0,0 @@
-.cta {
- background-color: clr(background-section);
- padding: $space-triple;
- text-align: center;
- text-transform: uppercase;
-}
diff --git a/components/02-molecules/cta/cta.twig b/components/02-molecules/cta/cta.twig
deleted file mode 100644
index c14a5ba..0000000
--- a/components/02-molecules/cta/cta.twig
+++ /dev/null
@@ -1,7 +0,0 @@
-{% set cta__base_class = 'cta' %}
-
-
{{ cta__heading }}
- {% include "@atoms/buttons/twig/button.twig" with {
- button_content: cta__button_text,
- } %}
-
diff --git a/components/02-molecules/cta/cta.yml b/components/02-molecules/cta/cta.yml
deleted file mode 100644
index 0a5d77c..0000000
--- a/components/02-molecules/cta/cta.yml
+++ /dev/null
@@ -1,2 +0,0 @@
-cta__heading: 'This is a reason to act'
-cta__button_text: 'Click here'
diff --git a/components/02-molecules/cta/ctas.stories.js b/components/02-molecules/cta/ctas.stories.js
deleted file mode 100644
index 3f9fbd4..0000000
--- a/components/02-molecules/cta/ctas.stories.js
+++ /dev/null
@@ -1,14 +0,0 @@
-import React from 'react';
-
-import cta from './cta.twig';
-
-import ctaData from './cta.yml';
-
-/**
- * Storybook Definition.
- */
-export default { title: 'Molecules/CTA' };
-
-export const ctaExample = () => (
-
-);
diff --git a/components/02-molecules/menus/_menu-item.twig b/components/02-molecules/menus/_menu-item.twig
deleted file mode 100644
index 6a350dc..0000000
--- a/components/02-molecules/menus/_menu-item.twig
+++ /dev/null
@@ -1,37 +0,0 @@
-{% if not item_modifiers %}
- {% set item_modifiers = [] %}
-{% endif %}
-{# Pass original item modifiers down to children #}
-{% set original_item_modifiers = item_modifiers %}
-{% if item.in_active_trail == TRUE %}
- {% set item_modifiers = item_modifiers|merge(['active']) %}
-{% endif %}
-{% if menu_level > 0 %}
- {% set item_modifiers = item_modifiers|merge(['sub', 'sub-' ~ menu_level]) %}
-{% endif %}
-{% if item.children %}
- {% set item_modifiers = item_modifiers|merge(['with-sub']) %}
-{% endif %}
-{# below could maybe be done without a loop? #}
-{% for modifier in item.modifiers %}
- {% set item_modifiers = item_modifiers|merge([modifier]) %}
-{% endfor %}
-
-{% set list_item_label = item_label %}
-{% set li_base_class = item_base_class|default(menu_class ~ '__item') %}
-{% set li_modifiers = item_modifiers %}
-{% set li_blockname = item_blockname %}
-
-{% extends "@atoms/lists/_list-item.twig" %}
- {% block list_item_content %}
- {% include "@atoms/links/link/link.twig" with {
- link_content: item.title,
- link_url: item.url,
- link_base_class: item_base_class|default(menu_class ~ '__link'),
- link_modifiers: item_modifiers,
- } %}
- {% if item.children %}
-
- {{ menus.menu_links(item.children, attributes, menu_level + 1, menu_class, menu_modifiers, menu_blockname, item_base_class, original_item_modifiers, item_blockname, directory) }}
- {% endif %}
- {% endblock %}
diff --git a/components/02-molecules/menus/_menu-list.twig b/components/02-molecules/menus/_menu-list.twig
deleted file mode 100644
index 359845a..0000000
--- a/components/02-molecules/menus/_menu-list.twig
+++ /dev/null
@@ -1,15 +0,0 @@
-{% set ul_base_class = menu_class %}
-{% set ul_modifiers = menu_modifiers %}
-{% set ul_blockname = menu_blockname %}
-
-{# List #}
-{% extends "@atoms/lists/ul.twig" %}
- {% block list_content %}
- {% for item in items %}
- {% include "@molecules/menus/_menu-item.twig" with {
- li_base_class: item_base_class,
- li_modifiers: item_modifiers,
- li_blockname: item_blockname,
- } %}
- {% endfor %}
- {% endblock %}
diff --git a/components/02-molecules/menus/_menu.twig b/components/02-molecules/menus/_menu.twig
deleted file mode 100644
index e7d2bc4..0000000
--- a/components/02-molecules/menus/_menu.twig
+++ /dev/null
@@ -1,46 +0,0 @@
-{#
-/**
- * @file
- * Theme override to display a menu.
- *
- * Available variables:
- * - menu_name: The machine name of the menu.
- * - items: A nested list of menu items. Each menu item contains:
- * - attributes: HTML attributes for the menu item.
- * - below: The menu item child items.
- * - title: The menu link title.
- * - url: The menu link url
- * - localized_options: Menu link localized options.
- * - is_expanded: TRUE if the link has visible children within the current
- * menu tree.
- * - is_collapsed: TRUE if the link has children within the current menu tree
- * that are not currently visible.
- * - in_active_trail: TRUE if the link is in the active trail.
- */
-#}
-
-{#
- We call a macro which calls itself to render the full tree.
- @see http://twig.sensiolabs.org/doc/tags/macro.html
-#}
-
-{% macro menu_links(items, attributes, menu_level, menu_class, menu_modifiers, menu_blockname, item_base_class, item_modifiers, item_blockname, directory) %}
- {% import _self as menus %}
- {% if items %}
-
- {# Set classes #}
- {% set menu_class = menu_class|default('menu') %}
- {% if not menu_modifiers %}
- {% set menu_modifiers = [] %}
- {% endif %}
- {% if menu_level > 0 %}
- {% set menu_modifiers = menu_modifiers|merge(['sub', 'sub-' ~ menu_level]) %}
- {% endif %}
-
- {% include "@molecules/menus/_menu-list.twig" %}
- {% endif %}
-{% endmacro %}
-
-{% import _self as menus %}
-
-{{ menus.menu_links(items, attributes, 0, menu_class, menu_modifiers, menu_blockname, item_base_class, item_modifiers, item_blockname, directory) }}
diff --git a/components/02-molecules/menus/breadcrumbs/_breadcrumbs.scss b/components/02-molecules/menus/breadcrumbs/_breadcrumbs.scss
deleted file mode 100644
index 97a710e..0000000
--- a/components/02-molecules/menus/breadcrumbs/_breadcrumbs.scss
+++ /dev/null
@@ -1,41 +0,0 @@
-$color-breadcrumb: clr(accent);
-$color-breadcrumb-hover: clr(accent-high);
-$color-breadcrumb-active: clr(text);
-
-.breadcrumb {
- @include list-reset;
-}
-
-.breadcrumb__item,
-.breadcrumb__link,
-.breadcrumb__link:link,
-.breadcrumb__link:visited,
-.breadcrumb__link:focus {
- color: $color-breadcrumb;
- text-decoration: none;
-}
-
-.breadcrumb__link:hover {
- color: $color-breadcrumb-hover;
-}
-
-.breadcrumb__item {
- display: inline-block;
- font-size: 0.75rem;
-
- &::after {
- content: '/';
- }
-
- &:last-child {
- color: $color-breadcrumb-active;
-
- &::after {
- content: '';
- }
- }
-}
-
-.breadcrumb_link:active {
- color: $color-breadcrumb-active;
-}
diff --git a/components/02-molecules/menus/breadcrumbs/breadcrumbs.twig b/components/02-molecules/menus/breadcrumbs/breadcrumbs.twig
deleted file mode 100644
index 05a3ce6..0000000
--- a/components/02-molecules/menus/breadcrumbs/breadcrumbs.twig
+++ /dev/null
@@ -1,27 +0,0 @@
-{#
-/**
- * @file
- * Theme override for a breadcrumb trail.
- *
- * Available variables:
- * - breadcrumb: Breadcrumb trail items.
- */
-#}
-{% set breadcrumb__base_class = 'breadcrumb' %}
-
-{% if breadcrumb %}
-
- {{ 'Breadcrumb' }}
-
- {% for item in breadcrumb %}
-
- {% if item.url %}
- {{ item.text }}
- {% else %}
- {{ item.text }}
- {% endif %}
-
- {% endfor %}
-
-
-{% endif %}
diff --git a/components/02-molecules/menus/breadcrumbs/breadcrumbs.yml b/components/02-molecules/menus/breadcrumbs/breadcrumbs.yml
deleted file mode 100644
index 05875dc..0000000
--- a/components/02-molecules/menus/breadcrumbs/breadcrumbs.yml
+++ /dev/null
@@ -1,6 +0,0 @@
-breadcrumb:
- - url: '#'
- text: 'Home'
- - url: '#'
- text: 'Parent Page'
- - text: 'Current Page'
diff --git a/components/02-molecules/menus/inline/_inline-menu.scss b/components/02-molecules/menus/inline/_inline-menu.scss
deleted file mode 100644
index 79e0a96..0000000
--- a/components/02-molecules/menus/inline/_inline-menu.scss
+++ /dev/null
@@ -1,26 +0,0 @@
-.inline-menu {
- @include list-reset;
-}
-
-.inline-menu__item {
- margin: 0 1em 0.5em 0;
-
- @include large {
- display: inline;
- margin-bottom: 1em;
- }
-
- &:last-child {
- margin-right: 0;
- }
-}
-
-.inline-menu__link {
- @include link;
-
- font-size: 0.75rem;
- font-weight: 600;
- letter-spacing: 1.5px;
- text-decoration: none;
- text-transform: uppercase;
-}
diff --git a/components/02-molecules/menus/inline/inline-menu.twig b/components/02-molecules/menus/inline/inline-menu.twig
deleted file mode 100644
index d39cb66..0000000
--- a/components/02-molecules/menus/inline/inline-menu.twig
+++ /dev/null
@@ -1,4 +0,0 @@
-{% include "@molecules/menus/_menu.twig" with {
- menu_class: 'inline-menu',
- items: inline_menu_items,
-} %}
diff --git a/components/02-molecules/menus/inline/inline-menu.yml b/components/02-molecules/menus/inline/inline-menu.yml
deleted file mode 100644
index 45382e7..0000000
--- a/components/02-molecules/menus/inline/inline-menu.yml
+++ /dev/null
@@ -1,7 +0,0 @@
-inline_menu_items:
- - title: 'Test'
- url: '#'
- - title: 'Number 2'
- url: '#'
- - title: 'Item Number 3'
- url: '#'
diff --git a/components/02-molecules/menus/main-menu/_00-main-menu.scss b/components/02-molecules/menus/main-menu/_00-main-menu.scss
deleted file mode 100644
index a380585..0000000
--- a/components/02-molecules/menus/main-menu/_00-main-menu.scss
+++ /dev/null
@@ -1,93 +0,0 @@
-/* Menu media */
-$main-menu-medium: $medium;
-
-/* Nav */
-.main-nav {
- display: none;
-
- @include medium {
- display: block;
- }
-
- &--open {
- background-color: clr(background);
- display: block;
- left: 0;
- overflow-y: scroll;
- position: absolute;
- top: 134px;
- right: 0;
- width: 100%;
- }
-}
-
-/* UL */
-.main-menu {
- @include list-reset;
-
- border-bottom: 1px solid;
- position: relative;
- z-index: 10;
-
- @include medium {
- border-bottom: none;
- }
-}
-
-/*
- * Sub Navigation
- */
-
-/* UL (Nested) */
-.main-menu--sub {
- border-bottom: none;
- height: 0;
- overflow: hidden;
- background-color: clr(muted);
- width: 100%;
-
- @include medium {
- background-color: clr(highlight-high);
- display: none;
- height: auto;
- left: 0;
- overflow: visible;
- padding: $space 0;
- position: absolute;
- top: 55px;
- width: 315px;
- z-index: 1;
- font-size: 0.9rem;
- opacity: 0.6;
- }
-
- &:hover {
- @include medium {
- opacity: 1;
- background-color: clr(accent-high);
- transition: all 0.2s;
-
- .main-menu__link--sub {
- color: clr(highlight);
-
- &.active,
- &:active,
- &:hover {
- color: clr(background);
- background-color: clr(accent-high);
- }
- }
- }
- }
-
- /* See main-menu.js */
- &-open {
- height: auto;
- }
-}
-
-.main-menu--sub-2 {
- @include medium {
- display: none; /* Never shown on large screens */
- }
-}
diff --git a/components/02-molecules/menus/main-menu/_01-main-menu-item.scss b/components/02-molecules/menus/main-menu/_01-main-menu-item.scss
deleted file mode 100644
index 8921d6f..0000000
--- a/components/02-molecules/menus/main-menu/_01-main-menu-item.scss
+++ /dev/null
@@ -1,85 +0,0 @@
-/* LI */
-.main-menu__item {
- border-top: 1px solid;
- display: flex;
- flex-wrap: wrap;
- position: relative;
-
- @include medium {
- border-top: none;
- display: inline-block;
-
- /* Only top level */
- &:not(.main-menu__item--sub):hover {
- background-color: clr(accent-high);
- transition: all 0.2s;
-
- & > .main-menu--sub {
- display: block;
- }
- }
- }
-
- &:hover {
- .main-menu__link::after {
- color: clr(text);
- }
- }
-}
-
-/* Expand Button */
-.expand-sub {
- background-color: clr(accent-high);
- cursor: pointer;
- display: block;
- width: 20%;
- color: $white;
- position: relative;
-
- @include medium {
- display: none;
- }
-
- /* Down triangle */
- &::after {
- border: 15px solid;
- border-bottom-color: transparent;
- border-left: 12px solid transparent;
- border-right: 12px solid transparent;
- content: '';
- display: block;
- margin-right: -12px;
- margin-top: -6px;
- position: absolute;
- right: 50%;
- top: 50%;
- width: 0;
- }
-
- /* See main-menu.js */
- &--open {
- background-color: clr(accent-high);
- color: clr(muted);
-
- &::after {
- border-top-color: transparent;
- border-bottom: 15px solid;
- margin-top: -20px;
- }
- }
-}
-
-/*
- * Sub Navigation
- */
-
-/* LI (Nested) */
-.main-menu__item--sub {
- @include medium {
- display: block;
-
- &:not(:first-child) {
- border-top: 1px solid clr(highlight);
- }
- }
-}
diff --git a/components/02-molecules/menus/main-menu/_02-main-menu-link.scss b/components/02-molecules/menus/main-menu/_02-main-menu-link.scss
deleted file mode 100644
index 03c5512..0000000
--- a/components/02-molecules/menus/main-menu/_02-main-menu-link.scss
+++ /dev/null
@@ -1,74 +0,0 @@
-/* A */
-.main-menu__link {
- color: clr(accent);
- display: block;
- font-size: 1.2rem;
- line-height: 1.5;
- padding: $space $space-triple $space $space-double;
- text-decoration: none;
- font-weight: bold;
-
- &--with-sub {
- width: 80%;
- }
-
- @include medium {
- border-bottom: none;
- display: inline-block;
- font-size: 1.1rem;
- padding: $space;
- text-transform: none;
- position: relative;
- width: auto;
-
- &.active,
- &:active,
- &:hover {
- color: clr(highlight-high);
- }
-
- &::after {
- color: $white;
- display: inline-block;
- content: '>';
- margin-left: 0.5rem;
- }
-
- &--sub::after {
- display: none;
- }
-
- &:hover {
- color: clr(background);
-
- &::after {
- color: clr(highlight);
- }
- }
- }
-}
-
-/*
- * Sub Navigation Links
- */
-
-/* A (Nested) */
-.main-menu__link--sub {
- color: clr(accent);
- padding-left: $space-triple;
- font-weight: normal;
-
- @include medium {
- color: clr(highlight);
- display: block;
- padding: $space-one-third $space;
- }
-}
-
-.main-menu--sub-2 {
- background-color: clr(highlight-high);
-}
-
-.main-menu__link--sub-2 {
- padding-left: $space-quadruple;
-}
diff --git a/components/02-molecules/menus/main-menu/_03-main-menu-toggle.scss b/components/02-molecules/menus/main-menu/_03-main-menu-toggle.scss
deleted file mode 100644
index f870cc0..0000000
--- a/components/02-molecules/menus/main-menu/_03-main-menu-toggle.scss
+++ /dev/null
@@ -1,61 +0,0 @@
-/* Toggle */
-.toggle-expand {
- @include link;
-
- @include medium {
- display: none;
- }
-
- display: inline-block;
- padding: $space;
- text-decoration: none;
- width: 100%;
-
- /* See main-menu.js */
- &--open {
- background-color: clr(accent);
- }
-}
-
-.toggle-expand__text {
- display: block;
- font-size: 0.9rem;
- font-weight: bold;
-
- .toggle-expand--open & {
- color: clr(background);
- }
-}
-
-/* SVG icon */
-.toggle-expand__icon {
- height: 3rem;
- width: 3rem;
- margin: 0 auto;
-}
-
-.toggle-expand__open {
- display: inline-block;
- text-align: center;
-
- .toggle-expand--open & {
- display: none;
- }
-}
-
-.toggle-expand__close {
- display: none;
- text-align: center;
-
- &::before {
- color: clr(background);
- content: 'X';
- display: block;
- font-size: 2.5rem;
- line-height: 2;
- }
-
- .toggle-expand--open & {
- display: inline-block;
- }
-}
diff --git a/components/02-molecules/menus/main-menu/main-menu.js b/components/02-molecules/menus/main-menu/main-menu.js
deleted file mode 100644
index 069f9a0..0000000
--- a/components/02-molecules/menus/main-menu/main-menu.js
+++ /dev/null
@@ -1,27 +0,0 @@
-Attach.behaviors.mainMenu = {
- attach(context) {
- const toggleExpand = context.getElementById('toggle-expand');
- const menu = context.getElementById('main-nav');
- if (menu) {
- const expandMenu = menu.getElementsByClassName('expand-sub');
-
- // Mobile Menu Show/Hide.
- toggleExpand.addEventListener('click', (e) => {
- toggleExpand.classList.toggle('toggle-expand--open');
- menu.classList.toggle('main-nav--open');
- e.preventDefault();
- });
-
- // Expose mobile sub menu on click.
- for (let i = 0; i < expandMenu.length; i += 1) {
- expandMenu[i].addEventListener('click', (e) => {
- const menuItem = e.currentTarget;
- const subMenu = menuItem.nextElementSibling;
-
- menuItem.classList.toggle('expand-sub--open');
- subMenu.classList.toggle('main-menu--sub-open');
- });
- }
- }
- },
-};
diff --git a/components/02-molecules/menus/main-menu/main-menu.twig b/components/02-molecules/menus/main-menu/main-menu.twig
deleted file mode 100644
index fb9c53a..0000000
--- a/components/02-molecules/menus/main-menu/main-menu.twig
+++ /dev/null
@@ -1,25 +0,0 @@
-{# {{ attach_library('emulsify/main-menu') }} #}
-
-{% if menu %}
-
-
-
- {% include "@atoms/images/icons/_icon.twig" with {
- icon_base_class: "icon",
- icon_blockname: "toggle-expand",
- icon_name: "menu",
- } %}
- Main Menu
-
-
- Close
-
-
-
- {% include "@molecules/menus/_menu.twig" with {
- menu_class: "main-menu",
- items: menu.get_items,
- } %}
-
-
-{% endif %}
diff --git a/components/02-molecules/menus/main-menu/main-menu.yml b/components/02-molecules/menus/main-menu/main-menu.yml
deleted file mode 100644
index d4e3938..0000000
--- a/components/02-molecules/menus/main-menu/main-menu.yml
+++ /dev/null
@@ -1,38 +0,0 @@
-menu:
- get_items:
- - title: 'Menu Item 1'
- url: '#'
- children:
- - title: 'Menu Item 1 Sub 1'
- url: '#'
- - title: 'Menu Item 1 Sub 2'
- url: '#'
- children:
- - title: 'Menu Item 1 Sub Sub 1'
- url: '#'
- - title: 'Menu Item 1 Sub Sub 2'
- url: '#'
- - title: 'Menu Item 1 Sub 3'
- url: '#'
- - title: 'Menu Item 1 Sub 4'
- url: '#'
- - title: 'Menu Item 2'
- url: '#'
- children:
- - title: 'Menu Item 2 Sub 1'
- url: '#'
- - title: 'Menu Item 2 Sub 2'
- url: '#'
- - title: 'Menu Item 2 Sub 3'
- url: '#'
- - title: 'Menu Item 3'
- url: '#'
- children:
- - title: 'Menu Item 3 Sub 1'
- url: '#'
- - title: 'Menu Item 3 Sub 2'
- url: '#'
- - title: 'Menu Item 3 Sub 3'
- url: '#'
- - title: 'Menu Item 3 Sub 4'
- url: '#'
diff --git a/components/02-molecules/menus/menus.stories.js b/components/02-molecules/menus/menus.stories.js
deleted file mode 100644
index 33f8360..0000000
--- a/components/02-molecules/menus/menus.stories.js
+++ /dev/null
@@ -1,33 +0,0 @@
-import React from 'react';
-import { useEffect } from '@storybook/client-api';
-
-import breadcrumb from './breadcrumbs/breadcrumbs.twig';
-import inlineMenu from './inline/inline-menu.twig';
-import mainMenu from './main-menu/main-menu.twig';
-import socialMenu from './social/social-menu.twig';
-
-import breadcrumbsData from './breadcrumbs/breadcrumbs.yml';
-import inlineMenuData from './inline/inline-menu.yml';
-import mainMenuData from './main-menu/main-menu.yml';
-import socialMenuData from './social/social-menu.yml';
-
-import './main-menu/main-menu';
-
-/**
- * Storybook Definition.
- */
-export default { title: 'Molecules/Menus' };
-
-export const breadcrumbs = () => (
-
-);
-export const inline = () => (
-
-);
-export const main = () => {
- useEffect(() => Attach.attachBehaviors(), []);
- return
;
-};
-export const social = () => (
-
-);
diff --git a/components/02-molecules/menus/social/_social-menu-item.twig b/components/02-molecules/menus/social/_social-menu-item.twig
deleted file mode 100644
index d8b693e..0000000
--- a/components/02-molecules/menus/social/_social-menu-item.twig
+++ /dev/null
@@ -1,8 +0,0 @@
-{% set li_base_class = 'item' %}
-{% set li_modifiers = item_modifiers %}
-{% set li_blockname = menu_class %}
-
-{% extends "@atoms/lists/_list-item.twig" %}
- {% block list_item_content %}
- {% include "@molecules/menus/social/_social-menu-link.twig" %}
- {% endblock %}
diff --git a/components/02-molecules/menus/social/_social-menu-link.twig b/components/02-molecules/menus/social/_social-menu-link.twig
deleted file mode 100644
index e2e73a4..0000000
--- a/components/02-molecules/menus/social/_social-menu-link.twig
+++ /dev/null
@@ -1,13 +0,0 @@
-{% set link_url = item.url %}
-{% set link_base_class = 'link' %}
-{% set link_modifiers = item_modifiers %}
-{% set link_blockname = menu_class %}
-
-{% extends "@atoms/links/link/link.twig" %}
- {% block link_content %}
- {% include "@atoms/images/icons/_icon.twig" with {
- icon_name: item.icon,
- icon_blockname: menu_class,
- } %}
- {{ item.title }}
- {% endblock %}
diff --git a/components/02-molecules/menus/social/_social-menu.scss b/components/02-molecules/menus/social/_social-menu.scss
deleted file mode 100644
index ada188c..0000000
--- a/components/02-molecules/menus/social/_social-menu.scss
+++ /dev/null
@@ -1,26 +0,0 @@
-.social-menu {
- @include list-reset;
-
- &__item {
- margin-bottom: $space-one-fourth;
- }
-
- &__link {
- display: flex;
- flex-flow: row nowrap;
- align-items: center;
- text-decoration: none;
- color: clr(text);
- }
-
- &__icon {
- height: 2rem;
- width: 2rem;
- margin-right: $space-one-half;
- fill: currentColor;
- }
-
- &__text {
- font-weight: bold;
- }
-}
diff --git a/components/02-molecules/menus/social/social-menu.twig b/components/02-molecules/menus/social/social-menu.twig
deleted file mode 100644
index 1facdfa..0000000
--- a/components/02-molecules/menus/social/social-menu.twig
+++ /dev/null
@@ -1,13 +0,0 @@
-{# Set classes #}
-{% set menu_class = 'social-menu' %}
-{% set ul_base_class = menu_class %}
-{% set ul_modifiers = menu_modifiers %}
-{% set ul_blockname = menu_blockname %}
-
-{# List #}
-{% extends "@atoms/lists/ul.twig" %}
- {% block list_content %}
- {% for item in social_menu_items %}
- {% include "@molecules/menus/social/_social-menu-item.twig" %}
- {% endfor %}
- {% endblock %}
diff --git a/components/02-molecules/menus/social/social-menu.yml b/components/02-molecules/menus/social/social-menu.yml
deleted file mode 100644
index 1976564..0000000
--- a/components/02-molecules/menus/social/social-menu.yml
+++ /dev/null
@@ -1,10 +0,0 @@
-social_menu_items:
- - title: 'Twitter'
- url: '#'
- icon: 'twitter'
- - title: 'Facebook'
- url: '#'
- icon: 'facebook'
- - title: 'Instagram'
- url: '#'
- icon: 'instagram'
diff --git a/components/02-molecules/pager/_pager.scss b/components/02-molecules/pager/_pager.scss
deleted file mode 100644
index 0acd361..0000000
--- a/components/02-molecules/pager/_pager.scss
+++ /dev/null
@@ -1,75 +0,0 @@
-.pager {
- margin: 2em 0;
-}
-
-.pager__items {
- @include list-reset;
-
- text-align: center;
-}
-
-.pager__item {
- display: inline-block;
- margin: 0 0.7em;
-
- @include large {
- margin: 0 1em;
- }
-}
-
-.pager__link,
-.pager__link:link,
-.pager__link:visited {
- color: clr(text);
- font-weight: 600;
- text-decoration: none;
-
- &.is-active,
- &:hover {
- color: clr(accent);
- }
-}
-
-.pager__link--current {
- color: $black;
-}
-
-.pager__link--next,
-.pager__link--prev {
- display: block;
-
- span {
- display: none;
- }
-
- &::before {
- border: 8px solid clr(text);
- border-bottom: 6px solid transparent;
- border-right: 7px solid transparent;
- border-top: 6px solid transparent;
- content: '';
- display: block;
- position: relative;
- top: 1px;
- }
-
- &:hover {
- &::before {
- border-left-color: clr(accent);
- }
- }
-}
-
-.pager__link--prev {
- &::before {
- border-left-color: transparent;
- border-right-color: clr(text);
- }
-
- &:hover {
- &::before {
- border-left-color: transparent;
- border-right-color: clr(accent);
- }
- }
-}
diff --git a/components/02-molecules/pager/pager-both.yml b/components/02-molecules/pager/pager-both.yml
deleted file mode 100644
index e9781b5..0000000
--- a/components/02-molecules/pager/pager-both.yml
+++ /dev/null
@@ -1,16 +0,0 @@
-posts:
- pagination:
- prev:
- - link: '#'
- next:
- - link: '#'
- pages:
- - link: '#'
- title: "1"
- - link: '#'
- title: "2"
- - current: 1
- title: "3"
- - link: '#'
- title: "4"
-
diff --git a/components/02-molecules/pager/pager-prev.yml b/components/02-molecules/pager/pager-prev.yml
deleted file mode 100644
index d50e2bb..0000000
--- a/components/02-molecules/pager/pager-prev.yml
+++ /dev/null
@@ -1,13 +0,0 @@
-posts:
- pagination:
- prev:
- - link: '#'
- pages:
- - link: '#'
- title: "1"
- - link: '#'
- title: "2"
- - link: '#'
- title: "3"
- - current: 1
- title: "4"
diff --git a/components/02-molecules/pager/pager.stories.js b/components/02-molecules/pager/pager.stories.js
deleted file mode 100644
index e8a2d0b..0000000
--- a/components/02-molecules/pager/pager.stories.js
+++ /dev/null
@@ -1,31 +0,0 @@
-import React from 'react';
-
-import pager from './pager.twig';
-
-import pagerData from './pager.yml';
-import pagerBothData from './pager-both.yml';
-import pagerPrevData from './pager-prev.yml';
-
-/**
- * Storybook Definition.
- */
-export default { title: 'Molecules/Menus/Pager' };
-
-export const pagerExample = () => (
- <>
- Pager:
-
- Pager with next and previous links:
-
- Pager with previous link:
-
- >
-);
diff --git a/components/02-molecules/pager/pager.twig b/components/02-molecules/pager/pager.twig
deleted file mode 100644
index b84acab..0000000
--- a/components/02-molecules/pager/pager.twig
+++ /dev/null
@@ -1,71 +0,0 @@
-{% set pager__base_class = 'pager' %}
-
-{% if posts.pagination.pages is not empty %}
-
-
-
- {# First #}
- {% if posts.pagination.pages|first and posts.pagination.pages|first.current != true %}
-
- First
-
- {% else %}
-
- First
-
- {% endif %}
-
- {# Previous #}
- {% if posts.pagination.prev %}
-
-
-
- {% else %}
-
- Previous
-
- {% endif %}
-
- {# Pages #}
- {% for page in posts.pagination.pages %}
- {% if page.link %}
-
- {{ page.title }}
-
- {# if it is current #}
- {% elseif page.current == true %}
-
- {{ page.title }}
-
- {% else %}
-
- {{ page.title }}
-
- {% endif %}
- {% endfor %}
-
- {# Next #}
- {% if posts.pagination.next %}
-
-
-
- {% else %}
-
- Next
-
- {% endif %}
-
- {# Last #}
- {% if posts.pagination.pages|last and posts.pagination.pages|last.current != true %}
-
- Last
-
- {% else %}
-
- Last
-
- {% endif %}
-
-
-
-{% endif %}
diff --git a/components/02-molecules/pager/pager.yml b/components/02-molecules/pager/pager.yml
deleted file mode 100644
index cb3a377..0000000
--- a/components/02-molecules/pager/pager.yml
+++ /dev/null
@@ -1,13 +0,0 @@
-posts:
- pagination:
- next:
- - link: '#'
- pages:
- - current: 1
- title: "1"
- - link: '#'
- title: "2"
- - link: '#'
- title: "3"
- - link: '#'
- title: "4"
diff --git a/components/02-molecules/status/_status.scss b/components/02-molecules/status/_status.scss
deleted file mode 100644
index bc49532..0000000
--- a/components/02-molecules/status/_status.scss
+++ /dev/null
@@ -1,27 +0,0 @@
-.status__list {
- @include list-reset;
-
- margin-bottom: $space;
-}
-
-.status {
- display: block;
- margin-bottom: $space-one-half;
- padding: $space-one-half;
- text-align: center;
-
- &--warning {
- color: $gray;
- background-color: clr(warning);
- }
-
- &--error {
- color: $gray;
- background-color: clr(error);
- }
-
- &--status {
- color: $gray;
- background-color: clr(message);
- }
-}
diff --git a/components/02-molecules/status/status.stories.js b/components/02-molecules/status/status.stories.js
deleted file mode 100644
index 19d7323..0000000
--- a/components/02-molecules/status/status.stories.js
+++ /dev/null
@@ -1,14 +0,0 @@
-import React from 'react';
-
-import status from './status.twig';
-
-import statusData from './status.yml';
-
-/**
- * Storybook Definition.
- */
-export default { title: 'Molecules/Status' };
-
-export const statusExamples = () => (
-
-);
diff --git a/components/02-molecules/status/status.twig b/components/02-molecules/status/status.twig
deleted file mode 100644
index 06e1821..0000000
--- a/components/02-molecules/status/status.twig
+++ /dev/null
@@ -1,42 +0,0 @@
-{#
-/**
- * @file
- * Theme override for status messages.
- *
- * Displays status, error, and warning messages, grouped by type.
- *
- * An invisible heading identifies the messages for assistive technology.
- * Sighted users see a colored box. See http://www.w3.org/TR/WCAG-TECHS/H69.html
- * for info.
- *
- * Add an ARIA label to the contentinfo area so that assistive technology
- * user agents will better describe this landmark.
- *
- * Available variables:
- * - message_list: List of messages to be displayed, grouped by type.
- * - status_headings: List of all status types.
- * - display: (optional) May have a value of 'status' or 'error' when only
- * displaying messages of that specific type.
- * - attributes: HTML attributes for the element, including:
- * - class: HTML classes.
- */
-#}
-{% set status__base_class = 'status' %}
-{% for type, messages in message_list %}
-
- {% if type == 'error' %}
-
- {% endif %}
- {% if type %}
-
{{ type }}
- {% endif %}
-
- {% for message in messages %}
- {{ message }}
- {% endfor %}
-
- {% if type == 'error' %}
-
- {% endif %}
-
-{% endfor %}
diff --git a/components/02-molecules/status/status.yml b/components/02-molecules/status/status.yml
deleted file mode 100644
index b12e077..0000000
--- a/components/02-molecules/status/status.yml
+++ /dev/null
@@ -1,7 +0,0 @@
-message_list:
- status:
- message: 'This is a status message'
- warning:
- message: 'This is a warning message'
- error:
- message: 'This is an error message'
diff --git a/components/02-molecules/tabs/_tab.scss b/components/02-molecules/tabs/_tab.scss
deleted file mode 100644
index f040061..0000000
--- a/components/02-molecules/tabs/_tab.scss
+++ /dev/null
@@ -1,44 +0,0 @@
-.tabs__link,
-.tabs__link--local-tasks {
- background-color: clr(muted);
- border: 1px solid clr(highlight-high);
- border-bottom: none;
- color: clr(text);
- display: block;
- font-size: 1.1rem;
- font-weight: 600;
- padding: 1em 2.4em;
- text-align: center;
- text-decoration: none;
- transition: color 0.3s;
-
- @include large {
- border-bottom: 1px solid clr(highlight-high);
- border-left: none;
- display: inline-block;
- font-size: 1rem;
- padding: 0.6em 1.7em;
- position: relative;
- top: 1px;
- width: auto;
- }
-
- &:hover {
- background-color: clr(highlight-high);
- color: clr(accent);
- }
-
- &.is-active {
- background-color: clr(text);
- border: 1px solid clr(text);
- color: clr(text-inverse);
-
- @include large {
- background-color: clr(background);
- border: 1px solid clr(highlight-high);
- border-bottom: 1px solid clr(highlight-high);
- border-left: none;
- color: clr(text);
- }
- }
-}
diff --git a/components/02-molecules/tabs/_tab.twig b/components/02-molecules/tabs/_tab.twig
deleted file mode 100644
index a7022b9..0000000
--- a/components/02-molecules/tabs/_tab.twig
+++ /dev/null
@@ -1,32 +0,0 @@
-{#
-/**
- * @file
- * Theme override for a local task link.
- *
- * Available variables:
- * - is_active: Whether the task item is an active tab.
- * - link: A rendered link element.
- */
-#}
-{%
- set link_classes = [
- is_active ? 'is-active' : '',
- 'tabs__link--local-tasks'
- ]
-%}
-
-{# if Storybook atoms example, add ul wrapper #}
-{% if example %}
-
-{% endif %}
-
- {# Detect if Wordpress #}
- {% if attributes %}
- {{ link(link['#title'], link['#url'], { 'class' : link_classes }) }}
- {% else %}
- {{ tab_text }}
- {% endif %}
-
-{% if example %}
-
-{% endif %}
diff --git a/components/02-molecules/tabs/_tabs.scss b/components/02-molecules/tabs/_tabs.scss
deleted file mode 100644
index 3cfb284..0000000
--- a/components/02-molecules/tabs/_tabs.scss
+++ /dev/null
@@ -1,41 +0,0 @@
-/* Tab Navigation */
-.tabs__nav {
- @include list-reset;
-
- border-bottom: 1px solid $gray-lightest;
-
- @include large {
- border-left: 1px solid $gray-lightest;
- display: flex;
- }
-}
-
-/* Tab Content (hidden only in full #tabs js version) */
-.tabs__tab {
- display: none;
-
- &.is-active {
- display: block;
- }
-}
-
-.tabs__content {
- padding: 1.5rem;
-}
-
-/* No-js fallback */
-.tabs.no-js {
- .tabs__tab.is-active {
- display: block;
- }
-}
-
-/* Wordpress Local Tasks variant */
-.tabs__nav--local-tasks {
- margin: 1em 0 0.5em;
- padding: 0;
-}
-
-.tabs__link--local-tasks {
- padding: 0.3em 1.5em;
-}
diff --git a/components/02-molecules/tabs/tabs.js b/components/02-molecules/tabs/tabs.js
deleted file mode 100644
index 3dbf0e4..0000000
--- a/components/02-molecules/tabs/tabs.js
+++ /dev/null
@@ -1,56 +0,0 @@
-Attach.behaviors.tabs = {
- attach(context) {
- const el = context.querySelectorAll('.tabs');
- const tabNavigationLinks = context.querySelectorAll('.tabs__link');
- const tabContentContainers = context.querySelectorAll('.tabs__tab');
- let activeIndex = 0;
-
- /**
- * goToTab
- * @description Goes to a specific tab based on index. Returns nothing.
- * @param {Number} index The index of the tab to go to
- */
- function goToTab(index) {
- if (
- index !== activeIndex &&
- index >= 0 &&
- index <= tabNavigationLinks.length
- ) {
- tabNavigationLinks[activeIndex].classList.remove('is-active');
- tabNavigationLinks[index].classList.add('is-active');
- tabContentContainers[activeIndex].classList.remove('is-active');
- tabContentContainers[index].classList.add('is-active');
- activeIndex = index;
- }
- }
-
- /**
- * handleClick
- * @description Handles click event listeners on each of the links in the
- * tab navigation. Returns nothing.
- * @param {HTMLElement} link The link to listen for events on
- * @param {Number} index The index of that link
- */
- function handleClick(link, index) {
- link.addEventListener('click', (e) => {
- e.preventDefault();
- goToTab(index);
- });
- }
-
- /**
- * init
- * @description Initializes the component by removing the no-js class from
- * the component, and attaching event listeners to each of the nav items.
- * Returns nothing.
- */
- for (let e = 0; e < el.length; e += 1) {
- el[e].classList.remove('no-js');
- }
-
- for (let i = 0; i < tabNavigationLinks.length; i += 1) {
- const link = tabNavigationLinks[i];
- handleClick(link, i);
- }
- },
-};
diff --git a/components/02-molecules/tabs/tabs.stories.js b/components/02-molecules/tabs/tabs.stories.js
deleted file mode 100644
index 5f81776..0000000
--- a/components/02-molecules/tabs/tabs.stories.js
+++ /dev/null
@@ -1,18 +0,0 @@
-import React from 'react';
-import { useEffect } from '@storybook/client-api';
-
-import tabs from './tabs.twig';
-
-import tabData from './tabs.yml';
-
-import './tabs';
-
-/**
- * Storybook Definition.
- */
-export default { title: 'Molecules/Tabs' };
-
-export const JSTabs = () => {
- useEffect(() => Attach.attachBehaviors(), []);
- return
;
-};
diff --git a/components/02-molecules/tabs/tabs.twig b/components/02-molecules/tabs/tabs.twig
deleted file mode 100644
index 84bf748..0000000
--- a/components/02-molecules/tabs/tabs.twig
+++ /dev/null
@@ -1,45 +0,0 @@
-{#
-/**
- * @file
- * Theme override to display primary and secondary local tasks.
- *
- * Available variables:
- * - primary: HTML list items representing primary tasks.
- * - secondary: HTML list items representing primary tasks.
- *
- * Each item in these variables (primary and secondary) can be individually
- * themed in menu-local-task.html.twig.
- */
-#}
-
-{# Wordpress Specific? #}
-{% if primary %}
- {{ 'Primary tabs'|t }}
-
-{% endif %}
-{% if secondary %}
- {{ 'Secondary tabs'|t }}
-
-{% endif %}
-
-{# Component Library Specific (javascript version) #}
-{% if not primary %}
-{# {{ attach_library('emulsify/tabs') }} #}
-
-
- {% for key, tab in tabs %}
- {% include "@molecules/tabs/_tab.twig" with {
- tab_link: "#tab-" ~ key,
- tab_text: tab.tab_text,
- } %}
- {% endfor %}
-
- {% for key, tab in tabs %}
-
-
-
{{ tab.tab_content }}
-
-
- {% endfor %}
-
-{% endif %}
diff --git a/components/02-molecules/tabs/tabs.yml b/components/02-molecules/tabs/tabs.yml
deleted file mode 100644
index 0cbfab7..0000000
--- a/components/02-molecules/tabs/tabs.yml
+++ /dev/null
@@ -1,7 +0,0 @@
-tabs:
- - tab_text: 'Tab 1'
- tab_content: 'Duis autem vel eum iriure dolor in hendrerit in vulputate velit esse molestie consequat, vel illum dolore eu feugiat nulla facilisis at vero eros et accumsan et iusto odio dignissim qui blandit praesent luptatum zzril delenit augue duis dolore te feugait nulla facilisi. Lorem ipsum dolor sit amet, consectetuer adipiscing elit, sed diam nonummy nibh euismod tincidunt ut laoreet dolore magna aliquam erat volutpat.'
- - tab_text: 'Tab 2'
- tab_content: 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet.'
- - tab_text: 'Tab 3'
- tab_content: 'Ut wisi enim ad minim veniam, quis nostrud exerci tation ullamcorper suscipit lobortis nisl ut aliquip ex ea commodo consequat. Duis autem vel eum iriure dolor in hendrerit in vulputate velit esse molestie consequat, vel illum dolore eu feugiat nulla facilisis at vero eros et accumsan et iusto odio dignissim qui blandit praesent luptatum zzril delenit augue duis dolore te feugait nulla facilisi.'
diff --git a/components/03-organisms/grid/_grid-examples.twig b/components/03-organisms/grid/_grid-examples.twig
deleted file mode 100644
index 0c55839..0000000
--- a/components/03-organisms/grid/_grid-examples.twig
+++ /dev/null
@@ -1,23 +0,0 @@
-{% if item_type == 'card' %}
- {% include "@molecules/card/card.twig" with {
- card__extra_classes: ['grid__item'],
- card__blockname: item.card__blockname,
- card__modifiers: item.card__modifiers,
- card__image__src: item.card__image__src,
- card__image__alt: item.card__image__alt,
- card__heading: item.card__heading,
- card__subheading: item.card__subheading,
- card__body: item.card__body,
- card__link__url: item.card__link__url,
- card__link__text: item.card__link__text,
- card__image__output_image_tag: item.card__image__output_image_tag,
- } %}
-{% elseif item_type == 'cta' %}
- {% include "@molecules/cta/cta.twig" with {
- cta__extra_classes: ['grid__item'],
- cta__heading: item.cta__heading,
- cta__button_text: item.cta__button_text,
- } %}
-{% else %}
-
-{% endif %}
diff --git a/components/03-organisms/grid/_grid-item.scss b/components/03-organisms/grid/_grid-item.scss
deleted file mode 100644
index 47e6265..0000000
--- a/components/03-organisms/grid/_grid-item.scss
+++ /dev/null
@@ -1,23 +0,0 @@
-@mixin grid-item {
- flex: 1 1 100%;
- margin-bottom: $space-double;
-
- @include large {
- margin: 0 $space-double 0 0;
- flex-basis: 30%;
- }
-
- &:last-child {
- margin-right: 0;
- }
-}
-
-.grid__item {
- @include grid-item;
-
- /* This is just for the component library example */
- &--example {
- background-color: clr(highlight-high);
- padding: $space-quadruple;
- }
-}
diff --git a/components/03-organisms/grid/_grid.scss b/components/03-organisms/grid/_grid.scss
deleted file mode 100644
index ac2e8e9..0000000
--- a/components/03-organisms/grid/_grid.scss
+++ /dev/null
@@ -1,12 +0,0 @@
-@mixin grid {
- display: flex;
- flex-wrap: wrap;
-}
-
-.grid {
- @include grid;
-
- &--card {
- @include space-stack-page;
- }
-}
diff --git a/components/03-organisms/grid/grid-cards.yml b/components/03-organisms/grid/grid-cards.yml
deleted file mode 100644
index f33417c..0000000
--- a/components/03-organisms/grid/grid-cards.yml
+++ /dev/null
@@ -1,24 +0,0 @@
-grid_label: 'Card Grid'
-grid_type: 'card'
-item_type: 'card'
-items:
- - card__image__src: 'https://placeimg.com/480/300/people'
- card__image__alt: 'People'
- card__image__output_image_tag: true
- card__heading: 'Super Awesome Card'
- card__link__url: '#'
- card__link__text: 'Click here'
- card__subheading: 'Person of Interest'
- card__body: 'Curabitur non nulla sit amet nisl tempus convallis quis ac lectus. Vivamus magna justo, lacinia eget consectetur sed, convallis at tellus. Donec rutrum congue leo eget malesuada. Proin eget tortor risus. Sed porttitor lectus nibh. Nulla quis lorem ut libero malesuada feugiat. Vivamus magna justo, lacinia eget consectetur sed, convallis at tellus. Pellentesque in ipsum id orci porta dapibus. Nulla quis lorem ut libero malesuada feugiat. Nulla quis lorem ut libero malesuada feugiat.'
- - card__image__src: 'https://placeimg.com/480/300/nature'
- card__image__alt: 'Nature'
- card__image__output_image_tag: true
- card__heading: 'Card Number Two'
- card__subheading: 'Some pretty sweet nature'
- card__body: 'Vivamus magna justo, lacinia eget consectetur sed, convallis at tellus. Donec rutrum congue leo eget malesuada. Proin eget tortor risus. Sed porttitor lectus nibh. Nulla quis lorem ut libero malesuada feugiat. Vivamus magna justo.'
- - card__image__src: 'https://placeimg.com/480/300/animals'
- card__image__alt: 'Animals'
- card__image__output_image_tag: true
- card__heading: 'The Last Card Heading'
- card__subheading: 'Awesome animals'
- card__body: 'Tempus convallis quis ac lectus. Vivamus magna justo, lacinia eget consectetur sed, convallis at tellus. Donec rutrum congue leo eget malesuada. Proin eget tortor risus. Sed porttitor lectus nibh. Nulla quis lorem ut libero malesuada feugiat.. Nulla quis lorem ut libero malesuada feugiat.'
diff --git a/components/03-organisms/grid/grid-ctas.yml b/components/03-organisms/grid/grid-ctas.yml
deleted file mode 100644
index 8efaa47..0000000
--- a/components/03-organisms/grid/grid-ctas.yml
+++ /dev/null
@@ -1,9 +0,0 @@
-grid_label: 'CTA Grid'
-item_type: 'cta'
-items:
- - cta__heading: 'This is an awesome CTA'
- cta__button_text: 'Click me!'
- - cta__heading: 'This is an even better CTA!!'
- cta__button_text: 'No, click me!'
- - cta__heading: 'This is a boring CTA'
- cta__button_text: 'Click me, I guess...'
diff --git a/components/03-organisms/grid/grid.twig b/components/03-organisms/grid/grid.twig
deleted file mode 100644
index d2d5485..0000000
--- a/components/03-organisms/grid/grid.twig
+++ /dev/null
@@ -1,27 +0,0 @@
-{% set grid_base_class = grid_base_class|default('grid') %}
-
-{% if not grid_modifiers %}
- {% set grid_modifiers = [] %}
-{% endif %}
-
-{% if page_layout_modifier %}
- {% set grid_modifiers = grid_modifiers|merge([page_layout_modifier]) %}
-{% endif %}
-
-{% if grid_type %}
- {% set grid_modifiers = grid_modifiers|merge([grid_type]) %}
-{% endif %}
-
-{% if grid_label %}
- {% include "@atoms/text/headings/_heading.twig" with {
- "heading_level": 2,
- "heading": grid_label,
- } %}
-{% endif %}
-
- {% block grid_content %}
- {% for item in items %}
- {% include "@organisms/grid/_grid-examples.twig" %}
- {% endfor %}
- {% endblock %}
-
diff --git a/components/03-organisms/grid/grid.yml b/components/03-organisms/grid/grid.yml
deleted file mode 100644
index 276c531..0000000
--- a/components/03-organisms/grid/grid.yml
+++ /dev/null
@@ -1,6 +0,0 @@
-page_layout_modifier: 'contained'
-grid_label: 'Default'
-items:
- -
- -
- -
diff --git a/components/03-organisms/grid/grids.stories.js b/components/03-organisms/grid/grids.stories.js
deleted file mode 100644
index 355b08c..0000000
--- a/components/03-organisms/grid/grids.stories.js
+++ /dev/null
@@ -1,26 +0,0 @@
-import React from 'react';
-
-import grid from './grid.twig';
-
-import gridData from './grid.yml';
-import gridCardData from './grid-cards.yml';
-import gridCtaData from './grid-ctas.yml';
-
-/**
- * Storybook Definition.
- */
-export default { title: 'Organisms/Grids' };
-
-export const defaultGrid = () => (
-
-);
-export const cardGrid = () => (
-
-);
-export const ctaGrid = () => (
-
-);
diff --git a/components/03-organisms/site/site-footer/_site-footer.scss b/components/03-organisms/site/site-footer/_site-footer.scss
deleted file mode 100644
index 7a687ff..0000000
--- a/components/03-organisms/site/site-footer/_site-footer.scss
+++ /dev/null
@@ -1,33 +0,0 @@
-.footer {
- background-color: clr(highlight-high);
- padding: $space 0;
-
- &__inner {
- @include wrapper;
-
- display: flex;
- flex-flow: column nowrap;
-
- @include large {
- flex-direction: row;
- }
- }
-}
-
-.footer__social {
- margin-bottom: $space;
-
- @include large {
- flex: 0 1 30%;
- margin-bottom: 0;
- margin-right: $space;
- }
-}
-
-.footer__menu {
- @include large {
- flex: 1 1 100%;
- margin-left: auto;
- text-align: right;
- }
-}
diff --git a/components/03-organisms/site/site-footer/site-footer.twig b/components/03-organisms/site/site-footer/site-footer.twig
deleted file mode 100644
index 68bbc3e..0000000
--- a/components/03-organisms/site/site-footer/site-footer.twig
+++ /dev/null
@@ -1,17 +0,0 @@
-{% set footer__base_class = 'footer' %}
-
-
-{{ function('wp_footer') }}
diff --git a/components/03-organisms/site/site-header/_site-header-branding.twig b/components/03-organisms/site/site-header/_site-header-branding.twig
deleted file mode 100644
index ba2c345..0000000
--- a/components/03-organisms/site/site-header/_site-header-branding.twig
+++ /dev/null
@@ -1,12 +0,0 @@
-{% set link_base_class = 'logo-link' %}
-{% set link_url = logo_link__url|default('/') %}
-
-{% extends "@atoms/links/link/link.twig" %}
- {% block link_content %}
- {% include "@atoms/images/image/responsive-image.twig" with {
- output_image_tag: true,
- image_src: header__logo_src,
- image_alt: 'Logo',
- responsive_image_blockname: 'logo',
- } %}
- {% endblock %}
diff --git a/components/03-organisms/site/site-header/_site-header.scss b/components/03-organisms/site/site-header/_site-header.scss
deleted file mode 100644
index 3eac1ce..0000000
--- a/components/03-organisms/site/site-header/_site-header.scss
+++ /dev/null
@@ -1,22 +0,0 @@
-.header {
- margin-bottom: 4em;
-
- &__inner {
- @include wrapper;
-
- display: flex;
- flex-flow: column nowrap;
- }
-
- &__primary {
- display: flex;
- flex-flow: row nowrap;
- justify-content: space-between;
- padding: $space 0;
- }
-
- &__branding {
- margin-right: $space;
- max-width: 300px;
- }
-}
diff --git a/components/03-organisms/site/site-header/site-header.twig b/components/03-organisms/site/site-header/site-header.twig
deleted file mode 100644
index 5ad6a93..0000000
--- a/components/03-organisms/site/site-header/site-header.twig
+++ /dev/null
@@ -1,26 +0,0 @@
-{% set header__base_class = 'header' %}
-{# If `theme` is defined, set the path relative for Wordpress.
- # Otherwise, set the path relative to the Component Library. #}
-{% set header__logo_src = theme ? theme.link ~ '/images/logo.png' : 'logo.png' %}
-
-
diff --git a/components/03-organisms/site/site.stories.js b/components/03-organisms/site/site.stories.js
deleted file mode 100644
index c7b8d2f..0000000
--- a/components/03-organisms/site/site.stories.js
+++ /dev/null
@@ -1,38 +0,0 @@
-import React from 'react';
-import { useEffect } from '@storybook/client-api';
-
-import footerTwig from './site-footer/site-footer.twig';
-import siteHeader from './site-header/site-header.twig';
-
-import footerSocial from '../../02-molecules/menus/social/social-menu.yml';
-import footerMenu from '../../02-molecules/menus/inline/inline-menu.yml';
-import breadcrumbData from '../../02-molecules/menus/breadcrumbs/breadcrumbs.yml';
-import mainMenuData from '../../02-molecules/menus/main-menu/main-menu.yml';
-
-import '../../02-molecules/menus/main-menu/main-menu';
-
-/**
- * Storybook Definition.
- */
-export default { title: 'Organisms/Site' };
-
-export const footer = () => (
-
-);
-export const header = () => {
- useEffect(() => Attach.attachBehaviors(), []);
- return (
-
- );
-};
diff --git a/components/04-templates/_default.scss b/components/04-templates/_default.scss
deleted file mode 100644
index b4f7063..0000000
--- a/components/04-templates/_default.scss
+++ /dev/null
@@ -1,73 +0,0 @@
-/*
- * General Layout
-*/
-
-$sidebar-width: 33%;
-$gutter: $space;
-$main-width: calc(100% - (#{$sidebar-width} + #{$gutter}));
-
-/*
- * Layout Using Flexbox (IE11+).
- * Remove this and uncomment Grid code below to use CSS Grid.
-*/
-
-.main {
- @include wrapper;
-
- margin-bottom: 4em;
-
- @include large {
- display: flex;
- }
-}
-
-.main-sidebar {
- margin-bottom: $space-double;
-
- @include large {
- order: 2;
- width: $sidebar-width;
- margin-bottom: 0;
- }
-}
-
-.main-content {
- width: 100%;
-
- @include large {
- &--with-sidebar {
- margin-right: $gutter;
- width: $main-width;
- }
- }
-}
-
-/*
- * Layout Using CSS Grid (NO IE11 support)
- * Remove Flexbox code above and uncomment this section to use CSS Grid.
-*/
-
-/* .main {
- @include wrapper;
-
- margin-bottom: 4em;
-
- &--with-sidebar {
- @include large {
- display: grid;
- grid-gap: $gutter;
- grid-template-areas: "content sidebar";
- grid-template-columns: $main-width #{$sidebar-width};
- }
- }
-}
-
-.main-sidebar {
- grid-area: sidebar;
- margin-bottom: $space-double;
-}
-
-.main-content {
- grid-area: content;
- width: 100%;
-} */
diff --git a/components/04-templates/_default.twig b/components/04-templates/_default.twig
deleted file mode 100644
index 2eea4c8..0000000
--- a/components/04-templates/_default.twig
+++ /dev/null
@@ -1,44 +0,0 @@
-{% set layout_modifiers = [] %}
-
-{% set main_base_class = main_base_class|default('main') %}
-{% set main_modifiers = [] %}
-
-{% set main_content_base_class = main_content_base_class|default('main-content') %}
-{% set main_content_modifiers = [] %}
-
-{% if sidebar %}
- {% set main_modifiers = main_modifiers|merge(['with-sidebar']) %}
- {% set main_content_modifiers = main_content_modifiers|merge(['with-sidebar']) %}
-{% endif %}
-
-{% block html_head_container %}
-
-{% include '@templates/_html-header.twig' %}
- {% block head %}
- {% endblock %}
-
-{% endblock %}
-
-
- {{ _e( 'Skip to content') }}
- {% block page_header %}
- {% include "@organisms/site/site-header/site-header.twig" %}
- {% endblock %}
-
-
-
- {% if sidebar %}
-
- {% block page_sidebar %}{% endblock %}
-
- {% endif %}
-
- {% block content %}{% endblock %}
-
-
-
- {% block page_footer %}
- {% include "@organisms/site/site-footer/site-footer.twig" %}
- {% endblock %}
-
-