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', '
Hero
' ); + emulsify_block_assets_smoke_write( $child . '/dist/components/hero/metadata.css', '.metadata-hero{}' ); + emulsify_block_assets_smoke_write( $child . '/dist/components/hero/manifest.css', '.manifest-hero{}' ); + + emulsify_block_assets_smoke_write_json( + $child . '/dist/components/plain/plain.component.json', + array( + 'title' => 'Plain', + ) + ); + emulsify_block_assets_smoke_write( $child . '/dist/components/plain/plain.twig', '

Plain

' ); + + emulsify_block_assets_smoke_write_json( + $child . '/dist/components/native/block.json', + array( + 'name' => 'emulsify/native', + 'style' => 'file:./style.css', + 'viewScript' => 'file:./view.js', + 'editorScript' => 'file:./editor.js', + ) + ); + emulsify_block_assets_smoke_write( $child . '/dist/components/native/style.css', '.native{}' ); + emulsify_block_assets_smoke_write( $child . '/dist/components/native/view.js', 'window.nativeView = true;' ); + emulsify_block_assets_smoke_write( $child . '/dist/components/native/editor.js', 'window.nativeEditor = true;' ); + + emulsify_block_assets_smoke_write_json( + $child . '/dist/emulsify-assets.json', + array( + 'assets' => array( + 'blocks' => array( + 'acf/emulsify-hero' => array( + 'frontend' => array( + 'css' => array( + array( + 'path' => 'components/hero/manifest.css', + 'relative' => 'hero-manifest.css', + 'version' => 'manifest-hero-css', + ), + ), + ), + ), + ), + ), + ) + ); + + require_once $repo_root . '/includes/Support/FileDiscovery.php'; + require_once $repo_root . '/includes/Support/AssetRecord.php'; + require_once $repo_root . '/includes/Support/AssetEnqueuer.php'; + require_once $repo_root . '/includes/Support/Diagnostics.php'; + require_once $repo_root . '/includes/Support/AssetManifest.php'; + require_once $repo_root . '/includes/Runtime/Assets.php'; + require_once $repo_root . '/includes/Blocks/ComponentLocator.php'; + require_once $repo_root . '/includes/Blocks/AcfBlocks.php'; + require_once $repo_root . '/includes/Blocks/NativeBlocks.php'; + + add_filter( + 'emulsify_theme_acf_block_asset_records', + static function ( array $records, array $component ): array { + if ( 'card' === $component['relative'] && isset( $records['frontend']['css'][0] ) ) { + $records['frontend']['css'][0]['version'] = 'filtered-card-css'; + } + + return $records; + }, + 10, + 2 + ); + + $acf_blocks = new Emulsify\Theme\Blocks\AcfBlocks( new Emulsify\Theme\Blocks\ComponentLocator() ); + $acf_blocks->register_blocks(); + + 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 = '
Example PDF
'; + + emulsify_editor_enhancements_smoke_assert( + $content === apply_filters( 'render_block', $content, $file_block, null ), + 'File captions should be no-op by default.' + ); + + foreach ( array( 'enqueue_block_editor_assets', 'render_block_data', 'render_block' ) as $hook ) { + unset( $GLOBALS['emulsify_editor_enhancements_smoke_hooks'][ $hook ] ); + } + + add_filter( + 'emulsify_theme_editor_enhancements_config', + static function ( array $config ): array { + $config['columnsEqualHeight']['enabled'] = true; + $config['fileCaption']['enabled'] = true; + $config['embedVariations']['enabled'] = true; + $config['placement'] = array( + 'enabled' => true, + 'blocks' => array( 'acf/example-hero' ), + 'singleton' => true, + 'requireTop' => true, + ); + + return $config; + } + ); + + $service = new Emulsify\Theme\Editor\Enhancements(); + $service->register(); + + $asset_directories_seen = false; + $asset_files_seen = false; + + add_filter( + 'emulsify_theme_editor_asset_directories', + static function ( array $directories, string $directory ) use ( &$asset_directories_seen ): array { + $asset_directories_seen = $asset_directories_seen || 'dist/global/editor' === $directory; + + return $directories; + }, + 10, + 2 + ); + + add_filter( + 'emulsify_theme_editor_asset_files', + static function ( array $assets, string $directory ) use ( &$asset_files_seen ): array { + $asset_files_seen = $asset_files_seen || 'dist/global/editor' === $directory; + + return $assets; + }, + 10, + 2 + ); + + $parsed = apply_filters( 'render_block_data', $file_block, $file_block, null ); + + emulsify_editor_enhancements_smoke_assert( + false !== strpos( $parsed['attrs']['className'], 'existing-class' ) && false !== strpos( $parsed['attrs']['className'], 'has-media-caption' ), + 'File caption render_block_data should append the configured class.' + ); + + $GLOBALS['emulsify_editor_enhancements_smoke_captions'][42] = 'Media caption'; + $rendered = apply_filters( 'render_block', $content, $file_block, null ); + + emulsify_editor_enhancements_smoke_assert( + false !== strpos( $rendered, '

Media caption

' ), + 'File caption render_block should append the attachment caption.' + ); + + emulsify_editor_enhancements_smoke_write( $child . '/dist/global/editor/js/index.js', 'window.editorSmoke = true;' ); + emulsify_editor_enhancements_smoke_write( $child . '/dist/global/editor/css/index.css', '.editor-smoke{}' ); + emulsify_editor_enhancements_smoke_write( $parent . '/dist/global/editor/js/index.js', 'window.parentEditorSmoke = true;' ); + + do_action( 'enqueue_block_editor_assets' ); + + emulsify_editor_enhancements_smoke_assert( + $asset_directories_seen, + 'emulsify_theme_editor_asset_directories should run during editor asset discovery.' + ); + emulsify_editor_enhancements_smoke_assert( + $asset_files_seen, + 'emulsify_theme_editor_asset_files should run during editor asset discovery.' + ); + emulsify_editor_enhancements_smoke_assert( + 1 === count( $GLOBALS['emulsify_editor_enhancements_smoke_styles'] ), + 'Editor asset enqueue should include the child editor stylesheet.' + ); + emulsify_editor_enhancements_smoke_assert( + 1 === count( $GLOBALS['emulsify_editor_enhancements_smoke_scripts'] ), + 'Editor asset enqueue should include the child editor script and skip duplicate parent relative paths.' + ); + emulsify_editor_enhancements_smoke_assert( + in_array( 'wp-blocks', $GLOBALS['emulsify_editor_enhancements_smoke_scripts'][0]['deps'], true ), + 'Editor script should declare WordPress editor dependencies.' + ); + emulsify_editor_enhancements_smoke_assert( + ! empty( $GLOBALS['emulsify_editor_enhancements_smoke_inline'] ) + && false !== strpos( $GLOBALS['emulsify_editor_enhancements_smoke_inline'][0]['data'], 'emulsifyEditorEnhancements' ) + && false !== strpos( $GLOBALS['emulsify_editor_enhancements_smoke_inline'][0]['data'], '"fileCaption":{"enabled":true' ), + 'Editor script should receive the filtered enhancement config.' + ); + + echo "Editor enhancements smoke checks passed.\n"; +} finally { + emulsify_editor_enhancements_smoke_remove( $work_root ); +} diff --git a/.github/scripts/editor-policy-smoke.php b/.github/scripts/editor-policy-smoke.php new file mode 100644 index 0000000..3ba0946 --- /dev/null +++ b/.github/scripts/editor-policy-smoke.php @@ -0,0 +1,380 @@ + $accepted_args, + 'callback' => $callback, + ); + + ksort( $GLOBALS['emulsify_editor_policy_smoke_filters'][ $hook ] ); + + return true; + } +} + +if ( ! function_exists( 'apply_filters' ) ) { + function apply_filters( string $hook, $value, ...$arguments ) { + if ( empty( $GLOBALS['emulsify_editor_policy_smoke_filters'][ $hook ] ) ) { + return $value; + } + + foreach ( $GLOBALS['emulsify_editor_policy_smoke_filters'][ $hook ] as $callbacks ) { + foreach ( $callbacks as $callback ) { + $value = call_user_func_array( + $callback['callback'], + array_slice( + array_merge( array( $value ), $arguments ), + 0, + $callback['accepted_args'] + ) + ); + } + } + + return $value; + } +} + +if ( ! function_exists( 'get_stylesheet_directory' ) ) { + function get_stylesheet_directory(): string { + return $GLOBALS['emulsify_editor_policy_smoke_child']; + } +} + +if ( ! function_exists( 'get_template_directory' ) ) { + function get_template_directory(): string { + return $GLOBALS['emulsify_editor_policy_smoke_parent']; + } +} + +if ( ! function_exists( 'get_post_type' ) ) { + function get_post_type( $post ): string { + return is_object( $post ) && isset( $post->post_type ) ? (string) $post->post_type : ''; + } +} + +if ( ! function_exists( 'current_user_can' ) ) { + function current_user_can( string $capability ): bool { + return in_array( $capability, $GLOBALS['emulsify_editor_policy_smoke_current_caps'], true ); + } +} + +/** + * Fails the smoke script when an assertion is false. + * + * @param bool $condition Assertion condition. + * @param string $message Failure message. + * @return void + */ +function emulsify_editor_policy_smoke_assert( bool $condition, string $message ): void { + if ( ! $condition ) { + throw new RuntimeException( $message ); + } +} + +/** + * Writes a fixture file. + * + * @param string $path File path. + * @param string $contents File contents. + * @return void + */ +function emulsify_editor_policy_smoke_write( string $path, string $contents ): void { + $directory = dirname( $path ); + + if ( ! is_dir( $directory ) && ! mkdir( $directory, 0777, true ) ) { + throw new RuntimeException( sprintf( 'Could not create fixture directory: %s', $directory ) ); + } + + if ( false === file_put_contents( $path, $contents ) ) { + throw new RuntimeException( sprintf( 'Could not write fixture file: %s', $path ) ); + } +} + +/** + * Recursively removes a path. + * + * @param string $path Path to remove. + * @return void + */ +function emulsify_editor_policy_smoke_remove( string $path ): void { + if ( ! file_exists( $path ) ) { + return; + } + + if ( is_file( $path ) || is_link( $path ) ) { + unlink( $path ); + return; + } + + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator( $path, RecursiveDirectoryIterator::SKIP_DOTS ), + RecursiveIteratorIterator::CHILD_FIRST + ); + + foreach ( $iterator as $item ) { + $item->isDir() && ! $item->isLink() ? rmdir( $item->getPathname() ) : unlink( $item->getPathname() ); + } + + rmdir( $path ); +} + +$repo_root = dirname( __DIR__, 2 ); +$work_root = sys_get_temp_dir() . '/emulsify-editor-policy-' . uniqid( '', true ); +$child = $work_root . '/child-theme'; +$parent = $work_root . '/parent-theme'; +$context = (object) array( + 'post' => (object) array( + 'post_type' => 'page', + ), +); + +$GLOBALS['emulsify_editor_policy_smoke_child'] = $child; +$GLOBALS['emulsify_editor_policy_smoke_parent'] = $parent; + +try { + require_once $repo_root . '/includes/Editor/BlockNames.php'; + require_once $repo_root . '/includes/Editor/PolicyOptions.php'; + require_once $repo_root . '/includes/Editor/PatternGovernance.php'; + require_once $repo_root . '/includes/Editor/AllowedBlockTypes.php'; + require_once $repo_root . '/includes/Editor/UserPatternPermissions.php'; + require_once $repo_root . '/includes/Editor/BlockSupportOverrides.php'; + require_once $repo_root . '/includes/Editor/Policy.php'; + + $policy = new Emulsify\Theme\Editor\Policy(); + $policy->register(); + + foreach ( array( 'allowed_block_types_all', 'block_editor_settings_all', 'register_post_type_args', 'block_type_metadata_settings', 'register_block_type_args' ) as $hook ) { + emulsify_editor_policy_smoke_assert( + isset( $GLOBALS['emulsify_editor_policy_smoke_filters'][ $hook ] ), + sprintf( 'Editor policy should register the %s hook.', $hook ) + ); + } + + emulsify_editor_policy_smoke_assert( + true === apply_filters( 'allowed_block_types_all', true, $context ), + 'Editor policy should preserve default allowed block behavior when unconfigured.' + ); + + $default_settings = array( + 'blockPatterns' => array( + array( + 'name' => 'core/query', + ), + ), + 'enableUserPatterns' => true, + ); + + emulsify_editor_policy_smoke_assert( + $default_settings === apply_filters( 'block_editor_settings_all', $default_settings, $context ), + 'Editor policy should preserve editor settings when unconfigured.' + ); + + emulsify_editor_policy_smoke_assert( + array( 'capabilities' => array( 'create_posts' => 'edit_posts' ) ) === apply_filters( + 'register_post_type_args', + array( + 'capabilities' => array( + 'create_posts' => 'edit_posts', + ), + ), + 'wp_block' + ), + 'Editor policy should preserve wp_block capabilities when unconfigured.' + ); + + emulsify_editor_policy_smoke_write( + $child . '/patterns/hero.json', + (string) json_encode( + array( + 'content' => '

Intro

', + ) + ) + ); + + add_filter( + 'emulsify_theme_editor_policy_options', + static function ( array $options ) use ( $child ): array { + $options['allowed_block_types'] = array( + 'default' => array( 'core/paragraph', 'heading' ), + 'by_post_type' => array( + 'page' => array( 'core/image' ), + ), + ); + $options['auto_allow_pattern_blocks'] = true; + $options['pattern_directories'] = array( $child . '/patterns' ); + $options['pattern_namespaces'] = array( 'project', 'theme' ); + $options['disable_user_patterns_for_non_admins'] = true; + $options['admin_capability'] = 'manage_options'; + $options['restrict_wp_block_creation'] = true; + $options['wp_block_create_capability'] = 'manage_options'; + $options['block_support_overrides'] = array( + '*' => array( + 'supports' => array( + 'styles' => false, + ), + ), + 'core/button' => array( + 'styles' => array(), + 'supports' => array( + 'color' => array( + 'gradients' => false, + ), + ), + ), + ); + + return $options; + } + ); + + add_filter( + 'emulsify_theme_allowed_block_types', + static function ( ?array $blocks, string $post_type ): ?array { + if ( 'page' === $post_type && is_array( $blocks ) ) { + $blocks[] = 'core/quote'; + } + + return $blocks; + }, + 10, + 2 + ); + + add_filter( + 'emulsify_theme_pattern_namespaces', + static function ( array $namespaces ): array { + $namespaces[] = 'child'; + + return $namespaces; + } + ); + + add_filter( + 'emulsify_theme_block_support_overrides', + static function ( array $overrides, string $block_name, array $settings, string $source ): array { + if ( 'core/button' === $block_name && 'register_block_type_args' === $source ) { + $overrides['core/button']['supports']['anchor'] = false; + } + + return $overrides; + }, + 10, + 4 + ); + + $allowed = apply_filters( 'allowed_block_types_all', array( 'core/html' ), $context ); + + foreach ( array( 'core/html', 'core/paragraph', 'core/heading', 'core/image', 'core/quote', 'acf/project-hero' ) as $expected_block ) { + emulsify_editor_policy_smoke_assert( + in_array( $expected_block, $allowed, true ), + sprintf( 'Allowed block policy should include %s.', $expected_block ) + ); + } + + emulsify_editor_policy_smoke_assert( + 1 === count( array_keys( $allowed, 'core/paragraph', true ) ), + 'Allowed block policy should deduplicate configured and pattern-derived block names.' + ); + + $filtered_settings = apply_filters( + 'block_editor_settings_all', + array( + 'blockPatterns' => array( + array( + 'name' => 'project/hero', + ), + array( + 'name' => 'theme/card', + ), + array( + 'name' => 'child/banner', + ), + array( + 'name' => 'core/query', + ), + ), + 'enableUserPatterns' => true, + ), + $context + ); + $pattern_names = array_column( $filtered_settings['blockPatterns'], 'name' ); + + emulsify_editor_policy_smoke_assert( + array( 'project/hero', 'theme/card', 'child/banner' ) === $pattern_names, + 'Pattern namespace policy should keep only configured namespaces.' + ); + emulsify_editor_policy_smoke_assert( + false === $filtered_settings['enableUserPatterns'], + 'Editor policy should disable user-created patterns for users without the admin capability.' + ); + + $wp_block_args = apply_filters( 'register_post_type_args', array( 'capabilities' => array( 'edit_posts' => 'edit_posts' ) ), 'wp_block' ); + + emulsify_editor_policy_smoke_assert( + true === $wp_block_args['map_meta_cap'] && 'manage_options' === $wp_block_args['capabilities']['create_posts'], + 'Editor policy should restrict wp_block creation to the configured capability.' + ); + emulsify_editor_policy_smoke_assert( + array() === apply_filters( 'register_post_type_args', array(), 'post' ), + 'Editor policy should leave non-wp_block post type arguments alone.' + ); + + $metadata_settings = apply_filters( + 'block_type_metadata_settings', + array( + 'supports' => array( + 'spacing' => true, + ), + ), + array( + 'name' => 'core/button', + ) + ); + + emulsify_editor_policy_smoke_assert( + false === $metadata_settings['supports']['styles'] + && true === $metadata_settings['supports']['spacing'] + && false === $metadata_settings['supports']['color']['gradients'] + && array() === $metadata_settings['styles'], + 'Block support overrides should apply to metadata settings without dropping existing supports.' + ); + + $registration_args = apply_filters( + 'register_block_type_args', + array( + 'supports' => array( + 'anchor' => true, + ), + ), + 'core/button' + ); + + emulsify_editor_policy_smoke_assert( + false === $registration_args['supports']['styles'] + && false === $registration_args['supports']['anchor'] + && false === $registration_args['supports']['color']['gradients'], + 'Block support overrides should apply to register_block_type_args and honor source-aware filters.' + ); + + echo "Editor policy smoke checks passed.\n"; +} catch ( Throwable $throwable ) { + fwrite( STDERR, $throwable->getMessage() . "\n" ); + exit( 1 ); +} finally { + emulsify_editor_policy_smoke_remove( $work_root ); +} diff --git a/.github/scripts/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 @@ -[![Emulsify Design System](https://user-images.githubusercontent.com/409903/170579210-327abcdd-2c98-4922-87bb-36446a4cc013.svg)](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 - - - - - - - - -
- - Randy -
- Randy Oest -
-
- - Joe -
- Joe Tower -
-
- - Brian -
- Brian Lewis -
-
- - mikeethedude/ -
- mikeethedude -
-
+![Emulsify Design System](https://github.com/emulsify-ds/.github/blob/6bd435be881bd820bddfa05d88905efe29176a0a/assets/images/header.png) + +# 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 %} -
-

{{ motion.name }}

-
-
- {% 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 = () => ; - -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 ( - - ); -}; - -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), -} %} - - 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 @@ -
  • - -
  • 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 %} - {% for key, checkbox in checkboxes %} - {% include "@atoms/forms/checkbox/_checkbox-item.twig" with { - checkbox_item: checkbox.title, - checkbox_item_checked: checkbox.checked, - } %} - {% endfor %} -{% if not attributes %} -
    -
    -{% 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 @@ -
  • - -
  • 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 %} - {% for key, radio in radios %} - {% include "@atoms/forms/radio/_radio-item.twig" with { - radio_item: radio.title, - radio_item_checked: radio.checked - } %} - {% endfor %} -{% if not attributes %} -
    -
    -{% 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 @@ - 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 @@ -
    - - -
    - -
    - - -
    - Enter the password that accompanies your username. -
    -
    - -
    -
    - Web and Email Address (fieldset example) -
    - - -
    - -
    - - -
    -
    -
    - -
    - - -
    - -
    - - -
    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/' %} - - 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 1Information about somethingAnd this is important
    Row 2Information about somethingAnd this is important
    Row 3Information about somethingAnd this is important
    Row 4Information about somethingAnd this is important
    This is a table footer
    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 @@ -
    - {% for i in 1..6 %} -
    -

    h{{ i }}

    - This is a Header {{ i }} example -
    - {% endfor %} -
    -{% 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 H2O

    - -

    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_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: "" 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 %} - -{% 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 %} - -{% 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 %} - -{% 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 }}

    -
      {{ primary }}
    -{% endif %} -{% if secondary %} -

    {{ 'Secondary tabs'|t }}

    -
      {{ secondary }}
    -{% 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' %} - -
    - -
    - {% block footer__social %} - {% include "@molecules/menus/social/social-menu.twig" %} - {% endblock %} -
    -
    - {% block footer__menu %} - {% include "@molecules/menus/inline/inline-menu.twig" %} - {% endblock %} -
    -
    - -{{ 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' %} - -
    -
    -
    -
    - {% block header__branding %} - {% include "@organisms/site/site-header/_site-header-branding.twig" %} - {% endblock %} -
    -
    - {% block header__menu %} - {% include "@molecules/menus/main-menu/main-menu.twig" %} - {% endblock %} -
    -
    -
    - {% block header__breadcrumbs %} - {% include "@molecules/menus/breadcrumbs/breadcrumbs.twig" %} - {% endblock %} -
    -
    -
    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 %} - - - - {% block page_header %} - {% include "@organisms/site/site-header/site-header.twig" %} - {% endblock %} - -
    - - {% if sidebar %} - - {% endif %} -
    - {% block content %}{% endblock %} -
    -
    - - {% block page_footer %} - {% include "@organisms/site/site-footer/site-footer.twig" %} - {% endblock %} - - diff --git a/components/04-templates/_html-header.twig b/components/04-templates/_html-header.twig deleted file mode 100644 index ab62995..0000000 --- a/components/04-templates/_html-header.twig +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - - - - - - - - - {{ function('wp_head') }} diff --git a/components/04-templates/full-width.twig b/components/04-templates/full-width.twig deleted file mode 100644 index 729cb1f..0000000 --- a/components/04-templates/full-width.twig +++ /dev/null @@ -1,6 +0,0 @@ -{% extends "@templates/_default.twig" %} - {% block content %} - {% include "@templates/placeholder/place-holder.twig" with { - "place_holder": "Primary Content", - } %} - {% endblock %} diff --git a/components/04-templates/layouts.stories.js b/components/04-templates/layouts.stories.js deleted file mode 100644 index a1a8568..0000000 --- a/components/04-templates/layouts.stories.js +++ /dev/null @@ -1,28 +0,0 @@ -import React from 'react'; - -import fullWidthTwig from './full-width.twig'; -import withSidebarTwig from './with-sidebar.twig'; - -import mainMenu from '../02-molecules/menus/main-menu/main-menu.yml'; -import socialMenu from '../02-molecules/menus/social/social-menu.yml'; -import footerMenu from '../02-molecules/menus/inline/inline-menu.yml'; - -/** - * Storybook Definition. - */ -export default { title: 'Templates/Layouts' }; - -export const fullWidth = () => ( -
    -); -export const withSidebar = () => ( -
    -); diff --git a/components/04-templates/placeholder/_place-holder.scss b/components/04-templates/placeholder/_place-holder.scss deleted file mode 100644 index b978a68..0000000 --- a/components/04-templates/placeholder/_place-holder.scss +++ /dev/null @@ -1,17 +0,0 @@ -$color-place-holder: $gray-light; - -.place-holder { - color: $color-place-holder; - border: 4px dashed $color-place-holder; - padding: 50px; - - &__content { - font-size: 3.2rem; - line-height: 1.4; - width: 100%; - display: flex; - justify-content: center; - align-items: center; - text-align: center; - } -} diff --git a/components/04-templates/placeholder/place-holder.stories.js b/components/04-templates/placeholder/place-holder.stories.js deleted file mode 100644 index 7c52733..0000000 --- a/components/04-templates/placeholder/place-holder.stories.js +++ /dev/null @@ -1,12 +0,0 @@ -import React from 'react'; - -import placeHolderTwig from './place-holder.twig'; - -/** - * Storybook Definition. - */ -export default { title: 'Templates/Place Holder' }; - -export const placeHolder = () => ( -
    -); diff --git a/components/04-templates/placeholder/place-holder.twig b/components/04-templates/placeholder/place-holder.twig deleted file mode 100644 index b1a9dc7..0000000 --- a/components/04-templates/placeholder/place-holder.twig +++ /dev/null @@ -1,7 +0,0 @@ -{% set place_holder__base_class = 'place-holder' %} - -
    -
    - {{ place_holder|default('Place holder') }} -
    -
    diff --git a/components/04-templates/with-sidebar.twig b/components/04-templates/with-sidebar.twig deleted file mode 100644 index e0cba07..0000000 --- a/components/04-templates/with-sidebar.twig +++ /dev/null @@ -1,13 +0,0 @@ -{% set sidebar = true %} - -{% extends "@templates/_default.twig" %} - {% block content %} - {% include "@templates/placeholder/place-holder.twig" with { - "place_holder": "Primary Content", - } %} - {% endblock %} - {% block page_sidebar %} - {% include "@templates/placeholder/place-holder.twig" with { - "place_holder": "Secondary Content", - } %} - {% endblock %} diff --git a/components/05-pages/content-types/article.twig b/components/05-pages/content-types/article.twig deleted file mode 100644 index f395a56..0000000 --- a/components/05-pages/content-types/article.twig +++ /dev/null @@ -1,55 +0,0 @@ -{% embed "@templates/with-sidebar.twig" %} - {% block content %} - {% include "@atoms/text/headings/_heading.twig" with { - heading_level: '2', - heading: 'Page Intro', - } %} - {% include "@atoms/text/text/01-paragraph.twig" with { - paragraph_content: '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, At accusam aliquyam diam diam dolore dolores duo eirmod eos erat, et nonumy sed tempor et et invidunt justo labore Stet clita ea et gubergren, kasd magna no rebum. sanctus sea sed takimata ut vero voluptua. 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.', - } %} - {% include "@organisms/grid/grid.twig" with { - grid_label: 'Here\'s an awesome grid', - grid_type: 'card', - item_type: 'card', - items: { - 1: { - 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: card__link__text, - 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." - }, - 2: { - 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." - }, - 3: { - 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." - } - } - } %} - {% include "@molecules/cta/cta.twig" with { - cta__heading: 'Here\'s a cool thing!', - cta__button_text: 'Click me!', - } %} - {% endblock %} - {% block page_sidebar %} - {% include "@molecules/cta/cta.twig" with { - cta__heading: 'We have a podcast', - cta__button_text: 'Subscribe now!', - } %} - - {% endblock %} -{% endembed %} diff --git a/components/05-pages/content-types/content-types.stories.js b/components/05-pages/content-types/content-types.stories.js deleted file mode 100644 index da1f3b6..0000000 --- a/components/05-pages/content-types/content-types.stories.js +++ /dev/null @@ -1,34 +0,0 @@ -import React from 'react'; -import { useEffect } from '@storybook/client-api'; - -import '../../02-molecules/menus/main-menu/main-menu'; - -import articleTwig from './article.twig'; - -import mainMenuData from '../../02-molecules/menus/main-menu/main-menu.yml'; -import breadcrumbData from '../../02-molecules/menus/breadcrumbs/breadcrumbs.yml'; -import socialMenuData from '../../02-molecules/menus/social/social-menu.yml'; -import footerMenuData from '../../02-molecules/menus/inline/inline-menu.yml'; - -/** - * Storybook Definition. - */ -export default { title: 'Pages/Content Types' }; - -export const article = () => { - useEffect(() => Attach.attachBehaviors(), []); - return ( -
    - ); -}; diff --git a/components/05-pages/landing-pages/home.twig b/components/05-pages/landing-pages/home.twig deleted file mode 100644 index 1ee554b..0000000 --- a/components/05-pages/landing-pages/home.twig +++ /dev/null @@ -1,50 +0,0 @@ -{% set page_layout_modifier = 'full' %} - -{% embed "@templates/full-width.twig" %} - {% block content %} - {% include "@atoms/text/headings/_heading.twig" with { - heading_level: '2', - heading: 'Page Intro', - } %} - {% include "@atoms/text/text/01-paragraph.twig" with { - paragraph_content: '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, At accusam aliquyam diam diam dolore dolores duo eirmod eos erat, et nonumy sed tempor et et invidunt justo labore Stet clita ea et gubergren, kasd magna no rebum. sanctus sea sed takimata ut vero voluptua. 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.', - } %} - {% include "@organisms/grid/grid.twig" with { - grid_label: 'Here\'s an awesome grid', - grid_type: 'card', - item_type: 'card', - items: { - 1: { - 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_url, - card__link__text: card__link__text, - 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." - }, - 2: { - 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." - }, - 3: { - 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." - } - } - } %} - {% include "@molecules/cta/cta.twig" with { - cta__heading: 'Here\'s a cool thing!', - cta__button_text: 'Click me!', - } %} - {% endblock %} -{% endembed %} diff --git a/components/05-pages/landing-pages/landing-pages.stories.js b/components/05-pages/landing-pages/landing-pages.stories.js deleted file mode 100644 index 15d98c5..0000000 --- a/components/05-pages/landing-pages/landing-pages.stories.js +++ /dev/null @@ -1,35 +0,0 @@ -import React from 'react'; - -import '../../02-molecules/menus/main-menu/main-menu'; - -import home from './home.twig'; - -import mainMenuData from '../../02-molecules/menus/main-menu/main-menu.yml'; -import breadcrumbData from '../../02-molecules/menus/breadcrumbs/breadcrumbs.yml'; -import socialMenuData from '../../02-molecules/menus/social/social-menu.yml'; -import footerMenuData from '../../02-molecules/menus/inline/inline-menu.yml'; - -/** - * Storybook Definition. - */ -export default { - title: 'Pages/Landing Pages', - parameters: { - layout: 'fullscreen', - }, -}; - -export const homePage = () => ( -
    -); diff --git a/components/style.scss b/components/style.scss deleted file mode 100644 index 90a76c3..0000000 --- a/components/style.scss +++ /dev/null @@ -1,10 +0,0 @@ -// Libraries -@import '~normalize.css/normalize'; -@import '~breakpoint-sass/stylesheets/breakpoint'; - -// Components -@import '00-base/**/*.scss'; -@import '01-atoms/**/*.scss'; -@import '02-molecules/**/*.scss'; -@import '03-organisms/**/*.scss'; -@import '04-templates/**/*.scss'; diff --git a/composer.json b/composer.json index f6450cc..31173ad 100644 --- a/composer.json +++ b/composer.json @@ -1,35 +1,46 @@ { - "name": "emulsify-ds/emulsify-wordpress-theme", - "description": "Storybook development + Webpack Build + Wordpress theme", + "name": "emulsify-ds/emulsify-wordpress", + "description": "Timber-first WordPress parent theme for Emulsify that generates child themes with Emulsify Core 4, Vite, Storybook, and Twig", "type": "wordpress-theme", - "homepage": "http://emulsify.info", + "homepage": "https://www.emulsify.info", "license": "GPL-2.0-only", - "minimum-stability": "stable", "authors": [ - { - "email": "randy@fourkitchens.com", - "name": "Dean Oest" - }, - { - "email": "evan@fourkitchens.com", - "name": "Evan Willhite" - }, - { - "email": "brian@fourkitchens.com", - "name": "Brian Lewis" - } - ], - "repositories": [ - { - "type": "composer", - "url": "https://wpackagist.org" + { + "name": "Callin Mullaney", + "email": "callin@fourkitchens.com" + }, + { + "name": "Mike Goulding", + "email": "mike.goulding@fourkitchens.com" } ], "require": { - "timber/timber": "^1.15" + "php": ">=8.3", + "timber/timber": "^2.3" + }, + "autoload": { + "psr-4": { + "Emulsify\\Theme\\": "includes/" + } + }, + "config": { + "optimize-autoloader": true, + "sort-packages": true, + "allow-plugins": { + "dealerdirect/phpcodesniffer-composer-installer": true, + "composer/installers": true + } }, "require-dev": { - "phpunit/phpunit": "5.7.16|6.*", - "hellonico/timber-dump-extension": "^1.0" + "dealerdirect/phpcodesniffer-composer-installer": "^1.2", + "php-stubs/acf-pro-stubs": "^6.8", + "php-stubs/wordpress-stubs": "^6.9", + "php-stubs/wp-cli-stubs": "^2.12", + "phpcsstandards/phpcsextra": "^1.5", + "phpcsstandards/phpcsutils": "^1.2", + "phpstan/phpstan": "^2.2", + "squizlabs/php_codesniffer": "^3.13", + "szepeviktor/phpstan-wordpress": "^2.0", + "wp-coding-standards/wpcs": "^3.3" } } diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..b152719 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,30 @@ +# Emulsify WordPress docs + +## Getting started + +- [Upgrading from 1.x to 2.x](upgrading-1x-to-2x.md) +- [Parent and child theme architecture](parent-child-architecture.md) +- [WP-CLI child theme generation](wp-cli-child-theme-generation.md) + +## Authoring + +- [Timber and Twig authoring](timber-and-twig-authoring.md) +- [Component recipes](component-recipes.md) +- [Emulsify Core 4 and Vite workflow](core-4-vite-workflow.md) + +## Blocks and editor + +- [ACF/Twig blocks](acf-twig-blocks.md) +- [Native Gutenberg blocks](native-gutenberg-blocks.md) +- [Core block Twig rendering](core-block-twig-rendering.md) +- [Block patterns](block-patterns.md) +- [Editor policy](editor-policy.md) +- [Editor enhancements](editor-enhancements.md) +- [ACF Local JSON](acf-local-json.md) + +## Operations + +- [Asset loading](asset-loading.md) +- [Release process](release-process.md) +- [Sister-project parity contract](sister-project-parity.md) +- [Post-2.x optimization roadmap](post-2x-optimization-roadmap.md) diff --git a/docs/acf-local-json.md b/docs/acf-local-json.md new file mode 100644 index 0000000..c06487a --- /dev/null +++ b/docs/acf-local-json.md @@ -0,0 +1,65 @@ +# ACF Local JSON + +The parent theme supports ACF Local JSON as an optional child-theme convention. When ACF is active and the active child theme contains `config/acf-json`, Emulsify: + +- uses `config/acf-json` as the ACF JSON save path; +- adds `config/acf-json` to ACF JSON load paths; +- keeps ACF's default load path unless a project explicitly removes it. + +If ACF is not active, or the child theme does not contain `config/acf-json`, the service exits without changing ACF settings. + +## Project Workflow + +Generated child themes include `config/acf-json/.gitkeep` so the directory exists before field groups are created. + +Project teams should commit ACF JSON files in this directory whenever field groups, option pages, post types, or taxonomies are managed through ACF. Treat these files as configuration: + +- Create or edit field groups in the WordPress admin. +- Let ACF write JSON to `config/acf-json`. +- Review the changed JSON files in Git before committing. +- Commit the JSON with the code that depends on those fields. +- Pull the latest code and use ACF's sync UI when another environment needs updates. + +Do not place project field-group JSON in the parent theme. The parent owns the convention; the child theme owns project configuration. + +## Filters + +Disable the integration: + +```php +add_filter( 'emulsify_theme_acf_json_enabled', '__return_false' ); +``` + +Change the save path: + +```php +add_filter( + 'emulsify_theme_acf_json_save_path', + function ( string $save_path, string $default_path ): string { + return get_stylesheet_directory() . '/config/acf-json'; + }, + 10, + 2 +); +``` + +Remove ACF's default load path only when a project wants one source of truth: + +```php +add_filter( 'emulsify_theme_acf_json_remove_default_load_path', '__return_true' ); +``` + +Alter final load paths: + +```php +add_filter( + 'emulsify_theme_acf_json_load_paths', + function ( array $paths, string $save_path, array $incoming_paths ): array { + $paths[] = WP_CONTENT_DIR . '/shared-acf-json'; + + return $paths; + }, + 10, + 3 +); +``` diff --git a/docs/acf-twig-blocks.md b/docs/acf-twig-blocks.md new file mode 100644 index 0000000..db4ef67 --- /dev/null +++ b/docs/acf-twig-blocks.md @@ -0,0 +1,108 @@ +# ACF/Twig blocks + +Related: [Native Gutenberg blocks](native-gutenberg-blocks.md), [Core block Twig rendering](core-block-twig-rendering.md), [Block patterns](block-patterns.md). + +ACF/Twig blocks are optional. If ACF is not active, this registration path is skipped without affecting the base theme. + +## How discovery works + +The parent theme scans built component output under `dist/components`. A component can register as an ACF/Twig block when its built folder contains: + +- A `*.component.json` metadata file. +- A matching Twig template. + +The Twig template can match the metadata filename or the component directory name. + +Discovery is memoized during each request. Persistent discovery caching is enabled by default outside local, development, or `WP_DEBUG` environments; see [Parent and child theme architecture](parent-child-architecture.md#optional-persistent-discovery-cache). + +## Example + +Whisk does not include an active ACF/Twig block example. A project component system can add metadata like this and build it into `dist/components`: + +```text +src/components/example-card/example-card.component.json +``` + +After the child theme build, that metadata might be available at: + +```text +dist/components/example-card/example-card.component.json +dist/components/example-card/example-card.twig +``` + +This documentation example proves the shape, but it does not ship project field groups or content model assumptions. + +See [Component recipes](component-recipes.md) for a short `*.component.json` example and guidance on when to choose ACF/Twig blocks. + +## Registration + +The parent theme reads the component metadata, merges default block arguments, and calls `acf_register_block_type()`. Rendering happens through Timber with block data, ACF fields, preview state, and the normal global Timber context. + +For ACF/Twig component metadata, use an ACF-safe `name` such as `emulsify-hero` rather than a namespaced native block name such as `project/hero`. ACF's PHP registration API receives an un-namespaced slug and WordPress exposes the editor block under the `acf/` namespace. The parent normalizes accidental slashes or other unsupported characters to dashes before duplicate checks and registration. + +The final registration arguments can be adjusted with: + +```php +add_filter( + 'emulsify_theme_acf_block_args', + function ( array $args, array $component ): array { + if ( 'example-card' === $component['relative'] ) { + $args['category'] = 'design'; + } + + return $args; + }, + 10, + 2 +); +``` + +Component metadata can be adjusted before defaults are merged with `emulsify_theme_acf_block_metadata`. + +## Scoped assets + +ACF/Twig blocks can declare block-scoped frontend and editor assets. Prefer `dist/emulsify-assets.json` when build tooling can write one, because it keeps hashed filenames and dependencies in one place. If no matching manifest entry exists, the parent reads optional `assets` metadata from `*.component.json`. + +Component metadata paths are relative to the built component directory: + +```json +{ + "title": "Example Card", + "assets": { + "frontend": { + "css": [{ "path": "example-card.css", "version": "card-css-123" }], + "js": [{ "path": "example-card.js", "module": true }] + }, + "editor": { + "css": [{ "path": "example-card.editor.css" }], + "js": [{ "path": "example-card.editor.js", "dependencies": ["wp-blocks"] }] + } + } +} +``` + +When scoped assets are declared, they are enqueued from the ACF block `enqueue_assets` callback instead of being loaded globally for every page. Components without scoped metadata continue to use the global `dist/components` scanner fallback. + +Manifest block entries can target the final ACF block name: + +```json +{ + "assets": { + "blocks": { + "acf/emulsify-example-card": { + "frontend": { + "css": [{ "path": "components/example-card/example-card.css" }] + } + } + } + } +} +``` + +Scoped asset records can be adjusted before registration with `emulsify_theme_acf_block_asset_records`. + +## Duplicate handling + +The child theme is scanned before the parent theme. Duplicate component paths, duplicate component slugs, and duplicate final ACF block `name` values are skipped instead of being registered twice. + +When `WP_DEBUG` is enabled, skipped duplicates are logged and shown as admin-only notices to users who can edit themes. Normal frontend visitors do not see duplicate diagnostics. diff --git a/docs/asset-loading.md b/docs/asset-loading.md new file mode 100644 index 0000000..617588d --- /dev/null +++ b/docs/asset-loading.md @@ -0,0 +1,140 @@ +# Asset loading + +The parent theme loads built assets from the active child theme first, then parent fallback assets. This keeps project builds in the child theme while allowing the parent to provide reusable runtime behavior. + +## Resolution order + +Asset loading is manifest-first and scanner-backed. For each supported scope, the parent checks a valid child `dist/emulsify-assets.json` before a parent manifest. When no manifest exists, the manifest is invalid, or the manifest does not declare the requested scope, the recursive scanner remains the fallback. + +Manifest data is memoized for the life of the service instance during a PHP request. Scanner fallback records are also memoized per theme-relative directory, so the CSS and JavaScript enqueue passes do not walk the same directory tree twice. Filters still receive the same extension-specific asset records they received before memoization. + +## Scanner behavior + +When no usable manifest is present, Emulsify WordPress scans these built asset directories: + +- `dist/global` +- `dist/components` + +Global frontend CSS and JavaScript are loaded from `dist/global`. Component CSS and JavaScript are loaded from `dist/components`. + +`dist/global/editor` is reserved for block editor enhancement assets. Those files are loaded by the editor enhancement service on `enqueue_block_editor_assets` and are skipped by the generic global loader so editor-only files are not sent to normal frontend visitors. + +Scanner asset versions use `filemtime()` so browsers receive updated files after a rebuild. If the child and parent themes both contain a built asset with the same relative path, the child asset wins. Assets are sorted by root priority and relative path before they are enqueued. + +For ACF/Twig components, files declared in `*.component.json` `assets` metadata or keyed manifest block/component entries are treated as block-scoped assets and skipped by the global component scanner. Component files without scoped metadata still load through the global `dist/components` scanner. + +## Manifest behavior + +Projects can optionally ship `dist/emulsify-assets.json` to avoid recursive discovery during normal requests. The manifest is child-first: a valid child manifest is used before a parent manifest. If no manifest exists, if the manifest is invalid JSON, or if a scope is not declared, the current scanner path remains the fallback for that scope. + +Manifest entry paths are relative to the manifest directory. For `dist/emulsify-assets.json`, use paths such as `global/app.css` or `components/card.js`. + +Example: + +```json +{ + "assets": { + "global": { + "css": [ + { + "path": "global/app.css", + "version": "app-css-123", + "dependencies": ["wp-block-library"] + } + ], + "js": [ + { + "path": "global/app.js", + "hash": "app-js-123", + "module": false, + "dependencies": ["jquery"] + } + ] + }, + "editor": { + "css": [{ "path": "global/editor/editor.css" }], + "js": [{ "path": "global/editor/editor.js" }] + }, + "components": { + "css": [{ "path": "components/card.css" }], + "js": [{ "path": "components/card.js", "module": true }] + }, + "blocks": { + "emulsify/card": { + "css": [{ "path": "components/card/block.css" }], + "js": [{ "path": "components/card/block.js", "module": true }] + } + } + } +} +``` + +Manifest records may include: + +- `path`, or `file`, `src`, or `href` +- `relative` for handle generation when it should differ from `path` +- `version` or `hash` +- `dependencies` or `deps` +- `module` or `"type": "module"` for frontend scripts + +Block-specific entries are accepted under `blocks` and are used by ACF/Twig block registration when a matching block is rendered. Broad `components.css` and `components.js` entries still load globally. Keyed `components` or `blocks` entries are not auto-loaded on pages that do not render those blocks. If a manifest only declares keyed block/component entries, undeclared component files still use the scanner fallback. + +## Performance + +The scanner is simple and requires no build integration. A manifest is useful for larger projects because build tooling can write the exact asset list, hashes, and dependencies once, and PHP can usually avoid walking `dist/global` and `dist/components` during normal requests. + +## Asset filters + +Use `emulsify_theme_asset_directories` to add or adjust roots: + +```php +add_filter( + 'emulsify_theme_asset_directories', + function ( array $directories, string $directory ): array { + if ( 'dist/global' === $directory ) { + $directories[] = array( + 'path' => get_stylesheet_directory() . '/project-dist/global', + 'priority' => 0, + 'source' => 'project', + 'uri' => get_stylesheet_directory_uri() . '/project-dist/global', + ); + } + + return $directories; + }, + 10, + 2 +); +``` + +Use `emulsify_theme_asset_files` when a project needs to add, remove, or reorder individual files before enqueueing. Keep filtered records in the same shape as the parent records: `path`, `priority`, `relative`, `uri`, and `version`. + +Use `emulsify_theme_asset_manifest_path` to change the theme-relative manifest path: + +```php +add_filter( + 'emulsify_theme_asset_manifest_path', + function ( string $path ): string { + return 'dist/custom-assets.json'; + } +); +``` + +Use `emulsify_theme_asset_manifest_data` to adjust parsed manifest data before records are created: + +```php +add_filter( + 'emulsify_theme_asset_manifest_data', + function ( array $data, array $manifest ): array { + $data['assets']['global']['css'][0]['version'] = 'project-build'; + + return $data; + }, + 10, + 2 +); +``` + +Final frontend asset records still pass through `emulsify_theme_asset_files`. Editor asset records pass through `emulsify_theme_editor_asset_files`. + +ACF/Twig block-scoped asset records pass through `emulsify_theme_acf_block_asset_records` before the block registration callback is attached. diff --git a/docs/block-patterns.md b/docs/block-patterns.md new file mode 100644 index 0000000..3bebf1f --- /dev/null +++ b/docs/block-patterns.md @@ -0,0 +1,120 @@ +# Block patterns + +Related: [ACF/Twig blocks](acf-twig-blocks.md), [Native Gutenberg blocks](native-gutenberg-blocks.md), [Core block Twig rendering](core-block-twig-rendering.md). + +The parent theme registers JSON block patterns from active child and parent theme `patterns` directories. Discovery is child-first and scans direct `patterns/*.json` files only, so a child theme can override a parent pattern file by using the same JSON filename. `patterns/categories.json` and `patterns/_categories.json` are reserved for optional category metadata and are not registered as block patterns. + +If no pattern directory exists, or if WordPress pattern registration functions are unavailable, the service exits without changing editor behavior. + +## JSON shape + +Each pattern JSON file should include: + +```json +{ + "name": "project/example-pattern", + "title": "Example Pattern", + "description": "Short editor-facing summary.", + "categories": ["starter"], + "keywords": ["example", "starter"], + "postTypes": ["page"], + "viewportWidth": 1200, + "content": "

    Pattern content.

    " +} +``` + +Required fields: + +- `name`: Pattern name in `namespace/slug` form. +- `title`: Editor-facing pattern title. +- `content`: Serialized block markup. + +Optional fields: + +- `description`: Editor-facing summary. +- `categories`: Pattern category slugs. The parent registers discovered categories when WordPress supports `register_block_pattern_category()`. +- `keywords`: Inserter search terms. +- `postTypes`: Post type slugs where the pattern should appear. +- `viewportWidth`: Preview width used by the inserter. + +Invalid JSON files, missing required fields, duplicate filenames, and duplicate pattern names are skipped safely. Diagnostics are logged only when `WP_DEBUG` is enabled. + +## Category metadata + +Categories discovered from pattern JSON receive a readable fallback label from the slug. Projects can refine labels and descriptions with `patterns/categories.json` or `patterns/_categories.json`: + +```json +{ + "featured": { + "label": "Featured", + "description": "Featured content layouts." + } +} +``` + +Child theme category metadata extends parent metadata and overrides matching fields. Category metadata files are optional; only categories referenced by registered patterns are registered. + +## Filters + +Use `emulsify_theme_pattern_directories` to add or replace scanned directories: + +```php +add_filter( + 'emulsify_theme_pattern_directories', + function ( array $directories ): array { + $directories[] = array( + 'path' => get_stylesheet_directory() . '/project-patterns', + 'source' => 'project', + ); + + return $directories; + } +); +``` + +Use `emulsify_theme_pattern_data` to alter decoded JSON before validation: + +```php +add_filter( + 'emulsify_theme_pattern_data', + function ( array $data, array $file ): array { + if ( 'landing.json' === $file['relative'] ) { + $data['title'] = 'Landing Page'; + } + + return $data; + }, + 10, + 2 +); +``` + +Use `emulsify_theme_pattern_categories` to alter discovered category registration args: + +```php +add_filter( + 'emulsify_theme_pattern_categories', + function ( array $categories ): array { + $categories['starter']['label'] = 'Starter Layouts'; + + return $categories; + } +); +``` + +Use `emulsify_theme_pattern_args` to alter final registration args before `register_block_pattern()` runs: + +```php +add_filter( + 'emulsify_theme_pattern_args', + function ( array $args ): array { + $args['keywords'][] = 'project'; + + return $args; + } +); +``` + +Generated child themes include an empty `patterns` placeholder, but no content patterns by default. If a project adds JSON patterns under `whisk/patterns`, the generator rewrites pattern names from the `whisk/` namespace to the generated child theme machine name. + +See [Component recipes](component-recipes.md) for a short pattern example and guidance on when a pattern is a better fit than a custom block. diff --git a/docs/component-recipes.md b/docs/component-recipes.md new file mode 100644 index 0000000..54ed823 --- /dev/null +++ b/docs/component-recipes.md @@ -0,0 +1,161 @@ +# Component recipes + +Whisk intentionally keeps `src/components` empty until a project installs the component system it wants to use. The snippets below are recipes for generated child themes, not files that must ship in every starter. Add them to a project child theme only when that project needs them. + +Do not add a full component library to `whisk/src/components`. Use Whisk as a light starter, then let the selected Emulsify component system define the project source structure. + +## When to use each option + +Use a plain Twig component for reusable markup that is rendered from templates, ACF/Twig blocks, patterns, or other components. + +Use an ACF/Twig block when editors need a custom block backed by ACF fields and the frontend should render through Twig. + +Use a native Gutenberg block when the project needs WordPress Block API features such as attributes, supports, transforms, editor scripts, view scripts, or dynamic rendering. + +Use core block Twig rendering only when a project intentionally replaces the frontend output of an existing core block such as `core/paragraph` or `core/heading`. + +Use a block pattern when editors need a reusable layout made from existing blocks rather than a new block type. + +## Plain Twig component + +Example generated child theme path: + +```text +src/components/card/card.twig +``` + +```twig + +``` + +Include it from a Twig template with the generated project machine name: + +```twig +{% include "project_machine_name:card" with { + eyebrow: 'News', + title: post.title, + url: post.link +} only %} +``` + +## Storybook story + +Story files belong with the component source when the selected component system supports that convention: + +```text +src/components/card/card.stories.js +``` + +```js +import template from './card.twig'; + +export default { + title: 'Components/Card', +}; + +export const Default = { + render: (args) => template(args), + args: { + eyebrow: 'News', + title: 'Example card', + url: '#', + link_text: 'Read more', + }, +}; +``` + +Adjust the import and render shape to the selected component system's Storybook Twig loader. + +## Optional ACF/Twig block metadata + +Add `*.component.json` only when the component should register as an ACF/Twig block after the child theme build: + +```text +src/components/card/card.component.json +``` + +```json +{ + "name": "emulsify-card", + "title": "Card", + "description": "A short card block rendered with Twig.", + "category": "widgets", + "icon": "index-card", + "mode": "preview" +} +``` + +After build, the parent scans `dist/components` for the built Twig template and matching `*.component.json` metadata. Whisk does not ship active ACF/Twig block metadata by default. + +## Optional native Gutenberg block metadata + +Add `block.json` only when the component is a native WordPress block: + +```text +src/components/card/block.json +``` + +```json +{ + "apiVersion": 3, + "name": "project/card", + "title": "Card", + "category": "widgets", + "icon": "index-card", + "description": "A project card block.", + "supports": { + "html": false + }, + "textdomain": "project" +} +``` + +Native blocks usually need editor scripts, attributes, supports, and save or render behavior defined by the project's chosen block tooling. Whisk does not ship a native block example by default. + +## Optional pattern JSON + +Use a block pattern when existing blocks can express the editor experience: + +```text +patterns/card-feature.json +``` + +```json +{ + "name": "project/card-feature", + "title": "Card feature", + "description": "A simple card-style feature pattern.", + "categories": ["text"], + "content": "

    Feature title

    Feature copy.

    " +} +``` + +Patterns live in the generated child theme's `patterns` directory. The child theme generator updates copied starter pattern namespaces from `whisk/*` to the generated machine name, but new project patterns should use the project namespace directly. + +## Core block Twig rendering + +Use core block Twig rendering only for explicit replacements of existing WordPress core block output: + +```php +add_filter( 'emulsify_theme_core_block_twig_rendering_enabled', '__return_true' ); + +add_filter( + 'emulsify_theme_core_block_twig_template_map', + function ( array $map ): array { + $map['core/heading'] = 'dist/components/heading/heading.twig'; + return $map; + } +); +``` + +Keep this opt-in narrow. Replacing core block markup can affect block validation, editor expectations, accessibility, and plugin integrations. diff --git a/docs/core-4-vite-workflow.md b/docs/core-4-vite-workflow.md new file mode 100644 index 0000000..af0feba --- /dev/null +++ b/docs/core-4-vite-workflow.md @@ -0,0 +1,89 @@ +# Emulsify Core 4 and Vite workflow + +The generated child theme uses Emulsify Core 4, Vite, Storybook, Twig stories, Sass, and JavaScript. The parent theme does not build project assets directly. + +## Project metadata + +`whisk/project.emulsify.json` uses `"platform": "wordpress"` so Emulsify Core and Emulsify CLI tooling can load the WordPress platform adapter while this parent theme owns the reusable WordPress runtime. It also records `generatedFrom: "emulsify-wordpress"` and `generatedFromVersion` for future starter upgrades and support diagnostics. + +Keep this file in generated child themes. The child theme generator updates `project.name`, `project.machineName`, and generated source metadata, and Emulsify Core uses the same metadata for component-library behavior. + +If a selected component system needs legacy Twig namespaces, use Core's existing `variant.structureImplementations` array: + +```json +{ + "variant": { + "structureImplementations": [ + { + "name": "atoms", + "directory": "src/components/atoms" + } + ] + } +} +``` + +The WordPress runtime also reads that shape so `{% include "@atoms/example/example.twig" %}` works in PHP-rendered Timber templates without requiring a separate WordPress-only namespace file. Do not move these roots into `theme.json`; WordPress uses `theme.json` for editor settings and global styles, not Twig loader configuration. + +## Source directories + +The generated child theme intentionally does not ship a concrete component library. Emulsify CLI can install the component system a project chooses, and that system should own the source tree, Sass entrypoints, stories, and fixture data. + +For Emulsify CLI initialization, `whisk/` is also the source for the standalone `emulsify-wordpress-starter` repository. The starter hook runs after CLI dependency installation and updates package metadata, WordPress theme headers, project metadata, and optional JSON pattern namespaces for the generated project. + +Whisk keeps `src/components/.gitkeep` only as a placeholder for compatible systems. It does not include default `tokens.scss`, `foundation.scss`, or `layout.scss` files. + +Whisk keeps empty `assets/images` and `assets/icons` directories as project-owned theme asset placeholders. Use these for source files that belong to the theme repository, not for uploaded media library files. The optional `assets/fonts` placeholder is available when a project owns local font files. + +Whisk does not include a child `theme.json` by default. The parent theme provides the reusable WordPress editor/global-style baseline; generated child themes should add `theme.json` only when the project needs its own presets, settings, styles, templates, or style variations. + +The parent WordPress runtime has only two default build-output conventions: + +- `dist/global` for built global CSS and JavaScript. +- `dist/components` for built component assets, ACF/Twig block metadata, and native `block.json` metadata. + +A fresh Whisk child theme has no Vite input files until a component system is installed. Its build and watch scripts call Emulsify Core's Vite config directly, so Core owns build-system errors and reporting when required inputs are missing. + +## Core 4, Vite, and Storybook commands + +Run these from the generated child theme directory: + +| 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. | + +## Documentation-only component example + +The following shape is an example of a compatible component, not files shipped by Whisk: + +```text +src/components/example-card/ + example-card.twig + example-card.scss + example-card.data.json + example-card.stories.js + example-card.component.json +``` + +Project teams should add, rename, or remove component files according to the selected component system and project naming model. This parent theme does not require a button component or any specific foundation, layout, or token Sass files. + +See [Component recipes](component-recipes.md) for small Twig, Storybook, ACF/Twig, native block, and pattern examples that can be copied into a generated child theme when a project needs them. + +When a component system builds `*.component.json` files into `dist/components`, the parent theme can discover those files for optional ACF/Twig block registration. Documentation examples prove the shape, but Whisk does not register any starter ACF/Twig blocks by default. + +For new project component includes, prefer the generated child theme machine name from `project.emulsify.json`. The general form is `{% include "project_machine_name:component_name" %}`. The legacy `@components/component-name/component-name.twig` namespace remains supported for compatible component libraries, existing projects, shared templates, and migration work. + +Whisk keeps `patterns/.gitkeep` only as a placeholder. If a project adds JSON patterns, the child theme generator updates copied pattern namespaces from `whisk/*` to the generated machine name. + +## Build output + +The parent theme loads built files from the active child theme first, then parent fallbacks. Build the child theme before expecting component styles, JavaScript, ACF/Twig block metadata, or native block metadata to appear in WordPress. diff --git a/docs/core-block-twig-rendering.md b/docs/core-block-twig-rendering.md new file mode 100644 index 0000000..284bf27 --- /dev/null +++ b/docs/core-block-twig-rendering.md @@ -0,0 +1,83 @@ +# Core block Twig rendering + +Related: [ACF/Twig blocks](acf-twig-blocks.md), [Native Gutenberg blocks](native-gutenberg-blocks.md), [Block patterns](block-patterns.md). + +The parent theme includes an experimental `CoreBlockTwigRenderer` service that can render explicitly mapped WordPress blocks through Twig templates. It is disabled by default and does not change frontend output unless a child theme or project plugin opts in. + +Use this carefully. Replacing core block output can affect block validation, style supports, accessibility attributes, plugin integrations, and future WordPress markup changes. Keep Twig output compatible with the saved block attributes and test editing, saving, reloading, and frontend rendering for every mapped block. + +See [Component recipes](component-recipes.md) for guidance on when to choose core block Twig rendering instead of a plain Twig component, ACF/Twig block, native block, or pattern. + +## Enable the service + +Enable rendering and provide an explicit block-to-template map: + +```php +add_filter( 'emulsify_theme_core_block_twig_rendering_enabled', '__return_true' ); + +add_filter( + 'emulsify_theme_core_block_twig_template_map', + function ( array $map ): array { + $map['core/paragraph'] = 'dist/components/paragraph/paragraph.twig'; + $map['core/heading'] = 'dist/components/heading/heading.twig'; + + return $map; + } +); +``` + +Mapped template paths are child/parent theme relative. The active child theme is checked first, then the parent theme. If a block is not mapped, or the mapped Twig file is not readable, WordPress' original rendered block content is returned unchanged. + +## Twig context + +Mapped templates receive the normal Timber context plus these values: + +- `block`: parsed block metadata from `render_block`. +- `block_metadata`: alias for `block`. +- `block_name`: block name such as `core/paragraph`. +- `attributes`: parsed block attributes. +- `content`: WordPress' original rendered block content. +- `inner_content`: rendered inner content fallback for templates. +- `inner_blocks`: parsed inner block records when available. +- `wp_block`: the `WP_Block` instance when WordPress passes one. +- `twig_template`: the mapped Twig template. + +Use `emulsify_theme_core_block_twig_context` to add project-specific values: + +```php +add_filter( + 'emulsify_theme_core_block_twig_context', + function ( array $context, array $block ): array { + if ( 'core/heading' === ( $block['blockName'] ?? '' ) ) { + $context['heading_level'] = $context['attributes']['level'] ?? 2; + } + + return $context; + }, + 10, + 2 +); +``` + +## Optional class cleanup + +The renderer does not strip `wp-block` classes by default. If a project needs class cleanup, opt in separately: + +```php +add_filter( 'emulsify_theme_core_block_twig_cleanup_classes_enabled', '__return_true' ); +``` + +Cleanup uses `WP_HTML_Tag_Processor` when available and otherwise leaves Twig output unchanged. Use `emulsify_theme_core_block_twig_cleanup_class_names` to control exactly which classes are removed. + +## Error handling + +If a mapped template throws an exception or Timber is unavailable, normal frontend visitors receive WordPress' original block output. Diagnostics are only appended for users who can edit posts or edit theme options, and debug logging only runs when `WP_DEBUG` is enabled. + +## Filters + +- `emulsify_theme_core_block_twig_rendering_enabled` +- `emulsify_theme_core_block_twig_template_map` +- `emulsify_theme_core_block_twig_template` +- `emulsify_theme_core_block_twig_context` +- `emulsify_theme_core_block_twig_cleanup_classes_enabled` +- `emulsify_theme_core_block_twig_cleanup_class_names` diff --git a/docs/editor-enhancements.md b/docs/editor-enhancements.md new file mode 100644 index 0000000..ee823e0 --- /dev/null +++ b/docs/editor-enhancements.md @@ -0,0 +1,83 @@ +# Editor enhancements + +The parent theme can enqueue project-owned editor assets from `dist/global/editor` and pass shared configuration to those assets. Whisk does not ship editor source modules because the selected Emulsify component system and project requirements should own editor JavaScript. + +Every built editor script receives `window.emulsifyEditorEnhancements` from the `emulsify_theme_editor_enhancements_config` filter. Defaults keep every module disabled: + +```php +add_filter( + 'emulsify_theme_editor_enhancements_config', + function ( array $config ): array { + $config['columnsEqualHeight']['enabled'] = true; + $config['fileCaption']['enabled'] = true; + $config['embedVariations']['enabled'] = true; + $config['placement'] = array( + 'enabled' => true, + 'blocks' => array( 'acf/project-hero' ), + 'singleton' => true, + 'requireTop' => true, + ); + + return $config; + } +); +``` + +Run the child theme build after adding project editor source: + +```sh +npm --prefix web/app/themes/your-child-theme run build +``` + +The module names below are a stable configuration contract and documentation-only starter guidance. Projects that want these behaviors should implement their own editor source or install a component/editor package that consumes this config. + +## Columns equal height + +`columnsEqualHeight` is intended for a project editor module that adds an inspector toggle to `core/columns`. A matching implementation should store the configured boolean attribute and add the configured class to saved markup and the editor preview. + +Useful options: + +- `enabled`: turns the module on. +- `attribute`: stored block attribute. Default: `emulsifyEqualizeHeights`. +- `className`: saved/editor class. Default: `is-equal-height`. +- `label`, `panelTitle`, `helpEnabled`, `helpDisabled`: editor control text. + +## File media captions + +`fileCaption` is intended for a project editor module that adds an inspector toggle to `core/file`. When the configured attribute exists on a rendered File block, the parent PHP render hook can add the media library caption during rendering. + +Useful options: + +- `enabled`: turns the module on. +- `attribute`: stored block attribute. Default: `emulsifyShowMediaCaption`. +- `blockClassName`: class added to the parsed File block attributes. Default: `has-media-caption`. +- `captionClassName`: class used on the injected caption paragraph. Default: `wp-block-file__media-caption`. + +## Embed variations + +`embedVariations` is intended for a project editor module that keeps `core/embed` available while hiding provider-specific embed variations from the inserter. + +Useful options: + +- `enabled`: turns the module on. +- `allowedVariationNames`: optional variation names to keep visible. + +## Singleton top placement + +`placement` is intended for a project editor module that enforces one configured top-level block, optionally moving it to the top of the post and removing duplicates. This is useful for project-specific hero, alert, or page-header blocks without hard-coding those names in the parent theme. + +Useful options: + +- `enabled`: turns the module on. +- `blocks`: block names that represent the singleton block. +- `singleton`: prevents duplicates and sets block support `multiple` to false. +- `requireTop`: moves the first configured block to the top level at index 0. +- `notice`: snackbar text shown when enforcement changes the block list. + +## Asset filters + +The parent service discovers child assets first, then parent fallbacks. These filters are available for projects with custom build locations: + +- `emulsify_theme_editor_enhancements_config` +- `emulsify_theme_editor_asset_directories` +- `emulsify_theme_editor_asset_files` diff --git a/docs/editor-policy.md b/docs/editor-policy.md new file mode 100644 index 0000000..3b074d3 --- /dev/null +++ b/docs/editor-policy.md @@ -0,0 +1,108 @@ +# Editor policy + +The parent theme includes an `Editor\Policy` coordinator for project-specific block editor governance. It is intentionally no-op by default, so activating the parent theme does not change allowed blocks, visible patterns, user-created pattern behavior, `wp_block` capabilities, or registered block supports unless a child theme or project plugin configures the policy with filters. + +## Configure policy options + +Use `emulsify_theme_editor_policy_options` from a child theme or project plugin: + +```php +add_filter( + 'emulsify_theme_editor_policy_options', + function ( array $options ): array { + $options['allowed_block_types'] = array( + 'default' => array( + 'core/heading', + 'core/paragraph', + 'core/image', + ), + 'by_post_type' => array( + 'landing_page' => array( + 'acf/project-hero', + 'acf/project-card-grid', + ), + ), + ); + + $options['auto_allow_pattern_blocks'] = true; + $options['pattern_namespaces'] = array( 'project' ); + $options['disable_user_patterns_for_non_admins'] = true; + $options['restrict_wp_block_creation'] = true; + $options['wp_block_create_capability'] = 'manage_options'; + $options['block_support_overrides'] = array( + '*' => array( + 'supports' => array( + 'styles' => false, + ), + ), + ); + + return $options; + } +); +``` + +`allowed_block_types` may be a single list for every post type, or an array with `default` and `by_post_type` keys. Post-type entries are merged with the default list. If WordPress or another plugin has already returned an allowed-block array, Emulsify merges with it by default; set `merge_allowed_block_types` to `false` to use only the configured list. + +When `auto_allow_pattern_blocks` is enabled, block names found in JSON files under `patterns/` are added to the configured allow list. Comment shorthand such as `` is normalized to `core/paragraph`. + +## Fine-tune allowed blocks + +Use `emulsify_theme_allowed_block_types` when the final list needs request-aware logic: + +```php +add_filter( + 'emulsify_theme_allowed_block_types', + function ( ?array $blocks, string $post_type ): ?array { + if ( 'event' === $post_type ) { + $blocks[] = 'core/embed'; + } + + return $blocks; + }, + 10, + 2 +); +``` + +Return `null` to keep WordPress default behavior for that editor context. + +## Limit visible patterns + +Use `emulsify_theme_pattern_namespaces` to keep only project-owned pattern namespaces in the inserter: + +```php +add_filter( + 'emulsify_theme_pattern_namespaces', + function ( array $namespaces ): array { + $namespaces[] = 'project'; + + return $namespaces; + } +); +``` + +If no namespaces are configured, Emulsify leaves the pattern list untouched. + +## Override block supports and styles + +Use `emulsify_theme_block_support_overrides` or the `block_support_overrides` option to change supports/styles for all blocks or a specific block. Overrides run for both `block_type_metadata_settings` and `register_block_type_args`. + +```php +add_filter( + 'emulsify_theme_block_support_overrides', + function ( array $overrides, string $block_name, array $settings, string $source ): array { + $overrides['*']['supports']['styles'] = false; + + if ( 'core/button' === $block_name && 'register_block_type_args' === $source ) { + $overrides['core/button']['styles'] = array(); + } + + return $overrides; + }, + 10, + 4 +); +``` + +Keep role and capability grants in the child theme or a project plugin. The parent theme can point `wp_block` creation at a capability, but it does not mutate site roles. diff --git a/docs/native-gutenberg-blocks.md b/docs/native-gutenberg-blocks.md new file mode 100644 index 0000000..404bee8 --- /dev/null +++ b/docs/native-gutenberg-blocks.md @@ -0,0 +1,62 @@ +# Native Gutenberg blocks + +Related: [ACF/Twig blocks](acf-twig-blocks.md), [Core block Twig rendering](core-block-twig-rendering.md), [Block patterns](block-patterns.md). + +Native Gutenberg blocks use the WordPress Block API. They are separate from Twig components and ACF/Twig blocks. + +## Gutenberg and block paths + +Add a `block.json` file to a component folder and build the child theme so the block folder is available under `dist/components`. The parent theme registers matching folders with `register_block_type()`. + +The starter does not include an active native block example. Native blocks usually need project-specific editor scripts, attributes, supports, and block behavior, so projects should add them intentionally. + +See [Component recipes](component-recipes.md) for a short `block.json` example and guidance on when to choose native blocks. + +Discovery is memoized during each request. Persistent discovery caching is enabled by default outside local, development, or `WP_DEBUG` environments; see [Parent and child theme architecture](parent-child-architecture.md#optional-persistent-discovery-cache). + +## Block assets + +Native blocks should use standard `block.json` asset fields such as `style`, `script`, `viewScript`, `editorStyle`, `editorScript`, and `viewScriptModule`. The parent theme passes the block directory to `register_block_type()` and does not manually enqueue assets that WordPress can register from `block.json`. + +Use `dist/emulsify-assets.json` or ACF/Twig component metadata only for ACF/Twig blocks. Native block asset loading should stay in native block metadata so WordPress can load assets only when the block is present. + +## When to use native blocks + +Use native blocks when the project needs block editor APIs such as: + +- Attributes. +- Supports. +- Transforms. +- Editor scripts. +- View scripts. +- Server-side render callbacks. +- Block-specific assets. + +Keep native block metadata separate from ACF component metadata so the two registration systems remain predictable. + +## Discovery priority + +Built child theme components are discovered before built parent theme components. When a child and parent native block use the same relative path or the same `block.json` `name`, the first discovered definition wins and lower-priority duplicates are skipped. + +## Filter native block directories + +Project code can alter the directories before registration: + +```php +add_filter( + 'emulsify_theme_native_block_directories', + function ( array $directories ): array { + $directories[] = array( + 'path' => get_stylesheet_directory() . '/custom-blocks/hero', + 'relative' => 'hero', + 'source' => 'project', + 'metadata_path' => get_stylesheet_directory() . '/custom-blocks/hero/block.json', + 'name' => 'project/hero', + ); + + return $directories; + } +); +``` + +Duplicate native block names are still checked after this filter runs. diff --git a/docs/parent-child-architecture.md b/docs/parent-child-architecture.md new file mode 100644 index 0000000..6ba8355 --- /dev/null +++ b/docs/parent-child-architecture.md @@ -0,0 +1,121 @@ +# Parent and child theme architecture + +Emulsify WordPress is split into a reusable parent theme and generated project child themes. The goal is to keep parent runtime behavior stable while allowing each project to own its implementation details. + +## Parent theme responsibilities + +The parent theme owns reusable runtime behavior: + +- WordPress bootstrap code. +- Theme setup, menus, editor support, and image sizes. +- Timber integration and global context. +- Twig namespace registration and helper functions. +- Asset loading for built child and parent files. +- Optional ACF Local JSON save/load path support. +- Optional ACF/Twig block registration. +- Optional native Gutenberg block registration. +- Optional JSON block pattern registration. +- Optional block editor governance when configured by a child theme. +- Minimal Timber fallback templates. + +Most parent runtime code lives in `includes/`. The root `functions.php` should stay thin. + +Runtime classes are grouped by domain: `Runtime`, `Blocks`, `Editor`, `Acf`, `Cli`, `Support`, and `Twig`. Composer PSR-4 autoloading loads these classes when Composer is available, and Bootstrap keeps a small fallback loader for manual theme installs without Composer. + +## Child theme responsibilities + +Generated child themes own: + +- Project components in the structure supplied by the selected Emulsify component system. +- Project Sass and JavaScript defined by that component system or by the project. +- Storybook stories and data fixtures. +- Built Vite output under `dist/`. +- Intentional template overrides. +- Project-specific filters or hooks. + +The bundled Whisk starter is an example child theme. Real projects can generate a renamed child theme with WP-CLI. + +## Template fallback model + +The parent `templates/` directory contains complete minimal fallbacks. The generated child theme only ships a small example override at `whisk/templates/page.twig`. + +Timber resolves the `@templates` namespace child-first: + +1. Child theme `templates`. +2. Parent theme `templates`. + +The parent-only `@emulsify-tpl` namespace points at parent templates. Use it when a child override needs to extend the parent fallback it is replacing. + +## Component discovery priority + +Built child theme components are discovered before built parent theme components. When a child and parent component use the same relative path under `dist/components`, the child component wins for both ACF/Twig `*.component.json` metadata and native `block.json` metadata. + +Discovery is memoized for the current PHP request. ACF/Twig and native block registration share one filesystem scan by default. + +## Asset resolution priority + +Built assets are resolved child-first. When `dist/emulsify-assets.json` exists and is valid, the manifest is used before recursive scanner fallback for each asset scope. If a child manifest is valid, it takes priority over the parent manifest; if a scope is missing or invalid, that scope falls back to scanning built child and parent directories. + +Manifest reads are memoized per `AssetManifest` instance during a request. Scanner fallback also memoizes raw file records per theme-relative directory, then filters CSS and JavaScript in memory for each enqueue pass while preserving the existing asset filters. + +## Optional persistent discovery cache + +Persistent component discovery caching is enabled by default in production and staging environments, and disabled by default in local, development, or `WP_DEBUG` environments. Projects can still opt out or force it on with: + +```php +add_filter( 'emulsify_theme_component_discovery_cache_enabled', '__return_false' ); +``` + +The transient cache key includes the active stylesheet, parent template, child theme version, parent theme version, WordPress environment type, and `dist/emulsify-assets.json` filemtime when that manifest exists. The runtime also calls `clear_discovery_cache` on WordPress' `switch_theme` hook so changing themes invalidates the active cache key. + +Projects that customize component roots dynamically can add their own invalidation token with `emulsify_theme_component_discovery_cache_key_parts`. The default cache lifetime can be adjusted with `emulsify_theme_component_discovery_cache_ttl`. + +To clear the current cache key from project code, a maintenance command, or custom deployment logic, call: + +```php +\Emulsify\Theme\Blocks\ComponentLocator::clear_discovery_cache(); +``` + +For new project component includes, prefer the generated child theme machine name from `project.emulsify.json`, using the general form `{% include "project_machine_name:component_name" %}`. The legacy `@components/component-name/component-name.twig` namespace remains supported for compatible component libraries, existing projects, shared templates, and migration work. + +When a selected component system needs custom legacy namespaces, define them once in `project.emulsify.json` with Emulsify Core's `variant.structureImplementations` array. The WordPress runtime reads that same shape for Timber, keeping Storybook/Core and PHP-rendered templates aligned without adding Drupal-style `.info.yml` metadata. + +## Runtime filters + +Child themes and project plugins can extend parent behavior with focused WordPress filters: + +- `emulsify_theme_asset_directories` +- `emulsify_theme_asset_files` +- `emulsify_theme_twig_namespaces` +- `emulsify_theme_project_component_roots` +- `emulsify_theme_context` +- `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_component_roots` +- `emulsify_theme_component_discovery_cache_enabled` +- `emulsify_theme_component_discovery_cache_key_parts` +- `emulsify_theme_component_discovery_cache_ttl` +- `emulsify_theme_acf_block_metadata` +- `emulsify_theme_acf_block_args` +- `emulsify_theme_native_block_directories` +- `emulsify_theme_pattern_directories` +- `emulsify_theme_pattern_data` +- `emulsify_theme_pattern_categories` +- `emulsify_theme_pattern_args` +- `emulsify_theme_setup_options` +- `emulsify_theme_editor_policy_options` +- `emulsify_theme_allowed_block_types` +- `emulsify_theme_pattern_namespaces` +- `emulsify_theme_block_support_overrides` + +Use these filters for project-specific behavior before editing a parent class. Keep broad application logic in a project plugin when it is not theme-specific. + +## Emulsify project metadata + +The generated child theme includes `project.emulsify.json` with `"platform": "wordpress"`. Emulsify Core and Emulsify CLI tooling use that adapter value for WordPress-aware project behavior, while reusable WordPress runtime support remains in this parent theme. + +Generated child themes also record `generatedFrom: "emulsify-wordpress"` and `generatedFromVersion`. These fields identify the starter lineage for future upgrades, support diagnostics, and safer replacement checks. + +Use WordPress-native files for WordPress concerns: `style.css` headers for theme identity and `theme.json` for editor/global style settings. Use `project.emulsify.json` for Emulsify project metadata that Core and project tooling need to share. diff --git a/docs/post-2x-optimization-roadmap.md b/docs/post-2x-optimization-roadmap.md new file mode 100644 index 0000000..ed719b3 --- /dev/null +++ b/docs/post-2x-optimization-roadmap.md @@ -0,0 +1,71 @@ +# Post-2.x optimization roadmap + +Emulsify WordPress 2.0 should stay focused on the parent and generated child theme release contract. The items below are follow-up opportunities for focused minor releases, not 2.0 blockers. + +## Release posture + +- Ship 2.0 without adding new runtime features. +- Keep 2.0 changes limited to release hardening, documentation cleanup, and bug fixes. +- Group future improvements into small minor releases with focused smoke or PHPUnit coverage. + +## Done in 2.0 + +- Composer PSR-4 autoloading and grouped runtime directories under `includes/`. +- Shared `Support` helpers including `AssetRecord`, `AssetEnqueuer`, `Diagnostics`, `FileDiscovery`, and `ProjectConfig`. +- A named `ProjectComponentLoader` for `project_machine_name:component_name` Twig references. +- Optional manifest-driven asset loading through `dist/emulsify-assets.json`, with scanner fallback. +- Per-request memoization for manifest reads, asset discovery, editor config, and component discovery. +- Block-scoped asset support for ACF/Twig blocks while native blocks keep WordPress `block.json` asset handling. +- Optional persistent discovery caching that is enabled by default outside local, development, or `WP_DEBUG` environments. +- `switch_theme` cache invalidation and cache-key parts for theme versions, environment, and manifest mtime. +- Safer WP-CLI `--force` replacement checks using generated child theme metadata. + +## Recommended implementation order + +1. Keep the 2.0 release branch focused on release readiness only. +2. Add read-only CLI diagnostics such as `wp emulsify doctor`. +3. Migrate the highest-value smoke harness checks into PHPUnit or another WordPress-aware test layer. +4. Explore block.json-based ACF registration only after the current `*.component.json` path is stable in real projects. +5. Expand cache and manifest diagnostics after maintainers have real-world invalidation guidance feedback. + +## Code organization + +- Keep the current domain folders stable through the 2.0 release. +- Add tests around service boundaries before further class movement. +- Avoid reintroducing compatibility shims for class names that never shipped in a stable release. + +## Runtime architecture + +- Consider a small service-provider layer if Bootstrap grows again. +- Keep parent services focused on reusable WordPress runtime behavior. +- Keep project-specific behavior in child themes or site plugins. + +## Asset loading and performance + +- Add build-time manifest validation so malformed `dist/emulsify-assets.json` files fail earlier. +- Document CDN or asset-host filters if real projects need them. +- Add diagnostics that explain whether a page used manifest records, scanner fallback, or block-scoped assets. + +## Component/block discovery + +- Add `wp emulsify doctor` checks for active child theme metadata, generated lineage, Timber availability, build output, and component discovery state. +- Improve duplicate and cache diagnostics for optional persistent discovery caching without adding an admin UI. +- Keep invalidation guidance close to the filters that alter roots or cache-key parts. + +## CLI diagnostics + +- Start with read-only reporting before adding repair commands. +- Report parent install state, active child theme, `Template` header, `project.platform`, `generatedFrom`, `generatedFromVersion`, Timber status, and missing build output. +- Include cache status and manifest path checks once those messages are stable. + +## Editor and block feature modules + +- Keep editor enhancements opt-in and independently testable. +- Evaluate block.json-based ACF registration as a follow-up to the current ACF/Twig metadata path. +- Add feature-specific docs and smoke/PHPUnit coverage with each new editor or block module. + +## Documentation and support tooling + +- Migrate smoke harness behavior into PHPUnit where WordPress APIs are easier to model. +- Maintain upgrade checklists, troubleshooting notes, component recipes, and release checklists as support material. +- Keep examples as recipes instead of active starter files in `whisk/src/components`. diff --git a/docs/release-process.md b/docs/release-process.md new file mode 100644 index 0000000..754689d --- /dev/null +++ b/docs/release-process.md @@ -0,0 +1,78 @@ +# Release process + +This repository uses semantic-release with Conventional Commits. Releases are prepared from `main`, and Git tags use non-prefixed SemVer such as `2.0.0`. + +## Release branch target + +The `release-2.x` branch prepares the next stable release as `2.0.0`. Until that stable tag exists, the semantic-release config forces the first `main` publish to use a major release type and then verifies the computed release is exactly `2.0.0`. After the `2.0.0` tag exists, normal Conventional Commit analysis drives later releases. + +## Commit messages + +Use commit messages that describe the public change: + +- `fix: correct timber attribute helpers` creates a patch release. +- `feat: add native block registration` creates a minor release. +- `feat!: change generated child theme structure` or a `BREAKING CHANGE:` footer creates a major release. + +## Local checks + +Run: + +```sh +npm run pr:check +npm run release:check +npm run publish-test -- --no-ci +``` + +`pr:check` runs the practical, stubbed pull request suite: + +- Composer metadata validation. +- Composer dependency install for Twig smoke coverage. +- PHP linting. +- ACF Local JSON smoke test. +- Twig attribute helper smoke test. +- Twig switch tag smoke test. +- Child theme generator smoke test. +- Component locator smoke test. +- Parent theme filter smoke test. +- Whisk dependency installation. + +`pr:check` does not build the empty Whisk starter. Whisk's build script delegates directly to Emulsify Core, and that build is expected to fail until a project installs or configures a component system with Vite input files. + +`release:check` adds static release-readiness checks and the full WordPress fixture smoke path. The fixture installs the parent theme in an isolated WordPress site, generates and activates a child theme from Whisk, adds neutral built asset and block fixtures, renders frontend routes through Timber, fetches those built child assets, and checks ACF/Twig and native `block.json` discovery. It requires WP-CLI and MySQL. It skips gracefully when WP-CLI or database settings are unavailable unless `WP_SMOKE_REQUIRED=1` is set. + +## CI + +The WordPress Theme Readiness workflow runs on pull requests, manual dispatch, and a weekly schedule. + +Pull requests run pragmatic checks: + +- Clean root npm installation. +- Composer metadata validation. +- Runtime and full npm audits. +- PHP linting. +- The practical `pr:check` suite, including WP-CLI child theme generation smoke coverage, Whisk dependency installation, and standalone starter init smoke coverage for Emulsify CLI. +- Static release readiness checks through `release:check`. + +Normal pull requests do not start MySQL or run the full WordPress fixture. Manual and scheduled runs execute the full WordPress fixture smoke test with WP-CLI and MySQL so heavier runtime coverage stays available without slowing every pull request. + +Manual dispatch can also run the Whisk Storybook build and accessibility audit for extended frontend confidence. + +## 2.0 merge and release gate + +Before merging the 2.0 release branch, maintainers should run the full fixture through GitHub Actions: + +1. Open GitHub Actions for `emulsify-ds/emulsify-wordpress`. +2. Select the `WordPress Theme Readiness` workflow. +3. Choose `Run workflow`. +4. Select the `release-2.x` branch. +5. Keep `wordpress_fixture` enabled. It defaults to enabled for manual runs. +6. Leave `extended_checks` disabled unless Storybook and accessibility coverage is needed for that release decision. + +Success means both the `Practical theme readiness` job and the `WordPress fixture smoke` job pass. The fixture job installs WP-CLI, starts MySQL, sets `WP_SMOKE_REQUIRED=1`, and runs `npm run release:check` against the isolated WordPress fixture. Record the successful workflow run in the 2.0 release PR before merging. + +The semantic-release workflow remains release-gated. It runs release readiness, requires the full WordPress fixture smoke path with WP-CLI and MySQL, runs a semantic-release dry run, and then allows the final publish job only after the readiness job succeeds. Release publishing must not proceed when the full fixture path is skipped or failed. + +## License + +The 2.x branch uses GPL-2.0-only metadata across npm, Composer, WordPress theme headers, and repository license files. diff --git a/docs/sister-project-parity.md b/docs/sister-project-parity.md new file mode 100644 index 0000000..846dfd0 --- /dev/null +++ b/docs/sister-project-parity.md @@ -0,0 +1,93 @@ +# Sister-project parity contract + +Emulsify WordPress is the WordPress sister project to Emulsify Drupal. The projects should feel familiar to teams moving between CMS platforms while preserving the runtime conventions each CMS expects. + +This contract defines the shared Emulsify model first, then the intentional WordPress differences. + +## Shared Emulsify contract + +Emulsify WordPress and Emulsify Drupal share the same project boundary: + +- The parent theme owns reusable CMS runtime behavior. +- The generated child theme owns project implementation. +- Whisk is the starter used to generate real project child themes. +- Emulsify Core 4 provides the component workflow. +- Vite builds frontend assets. +- Storybook presents component examples. +- Twig is the component template language. +- Node 24 is the expected JavaScript runtime for generated child theme work. + +The parent runtime should stay reusable and predictable. It provides CMS integration, fallback rendering, component discovery, asset loading, and extension points. Project-specific styling, components, stories, data fixtures, templates, and behavior belong in the generated child theme or in project plugins when the behavior is not theme-specific. + +## Generated source and assets + +Generated child themes are expected to own component source and built output. The selected Emulsify component system defines the project source structure: + +- Whisk keeps an empty `src/components` placeholder for compatible systems, but it does not prescribe a component tree. +- Whisk does not ship default `tokens.scss`, `foundation.scss`, or `layout.scss` entrypoints. +- Storybook stories and data fixtures live with the component source chosen by the project. +- Built global assets are emitted under `dist/global`. +- Built component assets and block metadata are emitted under `dist/components`. + +WordPress should load built child theme output before parent fallback output. A generated child theme can override parent components or templates intentionally, while the parent theme keeps minimal fallbacks available for routes and runtime services. + +## Intentional WordPress differences + +Emulsify WordPress is not a Drupal runtime port. It keeps parity at the Emulsify project-model layer and uses WordPress-native integration where WordPress needs it. + +### Timber runtime + +Frontend rendering uses Timber. The parent theme owns Timber bootstrapping, global context, Twig namespace registration, route fallbacks, helper functions, and Emulsify-compatible switch tags. Child themes supply project Twig templates and component source. + +For new project component includes, prefer the generated child theme machine name from `project.emulsify.json`, using the general form `{% include "project_machine_name:component_name" %}`. The legacy `@components/component-name/component-name.twig` namespace remains supported for compatible component libraries, existing projects, shared templates, and migration work. + +Custom legacy namespaces should use Emulsify Core's existing `variant.structureImplementations` entries in `project.emulsify.json`. Do not add Drupal `.info.yml` files to the WordPress starter for this purpose. + +### Parent and child theme headers + +WordPress theme identity lives in `style.css` headers. The parent theme is installed as `emulsify`, and generated child themes declare `Template: emulsify` so WordPress uses the parent runtime. + +The generator updates the child theme `Theme Name`, `Text Domain`, `Template`, package metadata, and Emulsify project metadata for each generated project. + +### theme.json surface + +`theme.json` is the WordPress site and editor configuration surface. It carries editor settings, presets, and styles that WordPress and the Site Editor understand. This is separate from component source and should be treated as WordPress configuration, not as a replacement for Core 4 Sass and component assets. + +Whisk does not include a child `theme.json` by default. Add one in a generated child theme when the project needs WordPress editor/global-style overrides; do not add an empty file just to mirror the parent. + +### ACF/Twig block registration + +ACF/Twig block registration is an optional WordPress integration. Built component folders can include `*.component.json` metadata and a Twig template. When ACF is available, the parent theme registers those components with `acf_register_block_type()` and renders them through Timber. + +### Native block.json registration + +Native Gutenberg blocks use WordPress `block.json` metadata. Projects add `block.json` intentionally when they need native editor APIs such as attributes, supports, editor scripts, view scripts, transforms, or dynamic rendering. The parent theme registers discovered built block folders with `register_block_type()`. + +### WP-CLI child theme generation + +WordPress project generation is handled by the parent theme's WP-CLI command: + +```sh +wp emulsify "Acme Site" --machine-name=acme-site +``` + +The command copies Whisk to a sibling child theme and performs targeted metadata updates. It avoids broad recursive string replacement so generated projects keep predictable WordPress headers, package metadata, and Emulsify project metadata. + +Emulsify CLI uses the standalone `https://github.com/emulsify-ds/emulsify-wordpress-starter` repository for the same child theme layer. That starter is sourced from `whisk/`, runs `.cli/init.js` after CLI dependency installation, and keeps `Template: emulsify` so WordPress loads the parent runtime theme. + +### Emulsify platform metadata + +`project.emulsify.json` uses `"platform": "wordpress"` so Emulsify Core and Emulsify CLI tooling can load the WordPress platform adapter. It also records `generatedFrom: "emulsify-wordpress"` and `generatedFromVersion` so support and upgrade tooling can identify the WordPress starter lineage. WordPress runtime behavior still lives in this parent theme for the 2.x release line. + +This file may also hold Emulsify Core metadata such as `variant.structureImplementations`. `theme.json` remains the WordPress site and editor configuration surface, not a component-library or Twig namespace registry. + +## Parity guardrails + +Use this contract when evaluating future changes: + +- Keep reusable CMS runtime behavior in the parent theme. +- Keep project implementation in the generated child theme. +- Keep Whisk small enough to work as a starter. +- Keep generated child theme source aligned with Emulsify Core 4 conventions. +- Keep WordPress-specific behavior explicit rather than hiding it behind Drupal naming or assumptions. +- Keep `project.emulsify.json` on `"platform": "wordpress"` and `generatedFrom: "emulsify-wordpress"` so generated child themes advertise the WordPress platform adapter and starter lineage. diff --git a/docs/timber-and-twig-authoring.md b/docs/timber-and-twig-authoring.md new file mode 100644 index 0000000..c89d89b --- /dev/null +++ b/docs/timber-and-twig-authoring.md @@ -0,0 +1,148 @@ +# Timber and Twig authoring + +Timber provides the bridge between WordPress data and Twig templates. Emulsify WordPress keeps Twig authoring predictable by registering a small set of namespaces and helper functions from the parent theme. + +## Bedrock and Timber + +Timber is required for frontend rendering. Site projects can install Timber from the application-level Composer project or from the parent theme `composer.json`. If the parent theme owns the dependency, run: + +```sh +cd web/app/themes/emulsify +composer install +``` + +In Bedrock, prefer the application-level Composer project when that is where PHP dependencies are managed. Either approach works as long as WordPress loads the Composer autoloader before the theme renders. + +If Timber is missing, the parent theme shows an admin notice and stops frontend rendering with a clear runtime error. + +## Twig namespaces + +The parent theme registers these namespaces: + +- `@templates`: child templates first, then parent fallbacks. +- `@emulsify-tpl`: parent templates only. +- `@components`: child component paths first, then parent component paths. + +Use `@templates` for normal includes. Use `@emulsify-tpl` when a child override extends the parent template with the same relative path. + +```twig +{% extends '@emulsify-tpl/page.twig' %} + +{% block content %} +
    + {{ parent() }} +
    +{% endblock %} +``` + +## Component includes + +Install or author project components in the structure defined by the selected Emulsify component system. For compatible component roots, new project includes can use the generated child theme machine name from `project.emulsify.json`: + +```twig +{% include "project_machine_name:example-card" with { + heading: post.title +} only %} +``` + +The general form is `project_machine_name:component_name`. + +This reference resolves child-first compatibility roots, including `src/components/example-card/example-card.twig`, `src/components/example-card.twig`, `components/example-card/example-card.twig`, and `components/example-card.twig`. One-level grouped names such as `project_machine_name:ui/heading` resolve to paths like `src/components/ui/heading/heading.twig`. + +The legacy `@components/example-card/example-card.twig` namespace remains supported for compatible component libraries, existing projects, shared templates, and migration work: + +```twig +{% include "@components/example-card/example-card.twig" with { + heading: post.title +} only %} +``` + +Use `emulsify_theme_project_component_roots` to adjust roots for `project_machine_name:component_name` references. Use `emulsify_theme_twig_namespaces` for advanced runtime-only `@namespace/path.twig` paths. + +Root-level `components` and `src/components` directories are compatibility paths, not starter requirements. Do not remove existing `@components` includes during migration; both forms are supported. + +See [Component recipes](component-recipes.md) for a small Twig component and Storybook story example that can be added to a generated child theme. + +## Component-system namespaces + +For legacy `@namespace/path/to/template.twig` references that should work in both Emulsify Core and WordPress, configure the namespace roots in `project.emulsify.json` with Core's existing `variant.structureImplementations` shape: + +```json +{ + "project": { + "platform": "wordpress", + "name": "Example Project", + "machineName": "example_project" + }, + "variant": { + "structureImplementations": [ + { + "name": "atoms", + "directory": "src/components/atoms" + }, + { + "name": "molecules", + "directory": "src/components/molecules" + } + ] + } +} +``` + +Then includes such as this resolve through the configured child-theme-relative root: + +```twig +{% include "@atoms/button/button.twig" with { + text: 'Read more' +} only %} +``` + +Do not use `theme.json` for Twig namespaces. `theme.json` is WordPress configuration for editor settings, global styles, presets, templates, and style variations; it is not a Twig loader configuration file. + +## Attribute helpers + +The parent theme registers Core-style helpers for class and attribute handling: + +```twig +
    + {{ content }} +
    + +
    + {{ content }} +
    +``` + +The helpers return an attribute bag that serializes safely in Twig string contexts. + +## Switch statements + +The parent theme registers the Emulsify-compatible `switch`, `case`, `default`, and `endswitch` tags used by Emulsify Core in Storybook and Emulsify Tools in Drupal. Use `or` to match more than one value in a case: + +```twig +{% switch variant %} + {% case 'primary' or 'secondary' %} + {{ label }} + {% default %} + {{ label }} +{% endswitch %} +``` + +The `default` branch is optional and, when present, follows the case branches. Each matching case stops automatically, so switch blocks do not fall through to later cases. Matching follows PHP switch semantics in Timber and the equivalent scalar behavior in Emulsify Core's Twig.js runtime. + +## Global context + +The parent theme adds common values such as `site`, `theme`, `menu`, `post`, `wp`, and `body_class` to the Timber context. Child themes and project plugins can add values with: + +```php +add_filter( + 'emulsify_theme_context', + function ( array $context ): array { + $context['project'] = array( + 'name' => 'Acme Site', + ); + + return $context; + } +); +``` diff --git a/docs/upgrading-1x-to-2x.md b/docs/upgrading-1x-to-2x.md new file mode 100644 index 0000000..57b11c7 --- /dev/null +++ b/docs/upgrading-1x-to-2x.md @@ -0,0 +1,66 @@ +# Upgrading from 1.x to 2.x + +Emulsify WordPress 2.x changes the project model. The repository is now a Timber-first WordPress parent theme that ships a generated child theme starter. Projects should customize the child theme instead of editing parent runtime classes. + +## What changed + +- The parent theme owns WordPress runtime behavior in `includes/`. +- The parent theme owns minimal Timber fallback templates in `templates/`. +- Generated child themes own project components, template overrides, source Sass and JavaScript, and built assets. +- The selected Emulsify component system defines the component source structure; Whisk does not ship default foundation, layout, token, or button files. +- The starter child theme uses Emulsify Core 4, Vite, Storybook, and Twig. +- The generated child theme keeps `project.emulsify.json` set to `"platform": "wordpress"` for Emulsify Core and Emulsify CLI WordPress adapter support. +- New generated child themes also include `generatedFrom: "emulsify-wordpress"` and `generatedFromVersion` in `project.emulsify.json` so future upgrades and support checks can identify starter lineage. Older generated child themes may not include these fields until they are regenerated or updated intentionally. +- Release metadata is aligned around the `2.0.0` stable release. + +## Before upgrading + +Inventory the current project before moving code: + +- Custom WordPress hooks or theme setup changes. +- Template overrides. +- Component source files. +- Built assets committed to the theme. +- Block registration metadata. +- Any assumptions about old build scripts or paths. + +Do not move everything into the parent theme. The parent should stay reusable; project code belongs in the generated child theme or a project plugin. + +## Migration checklist + +1. Install the 2.x parent theme as `emulsify`. +2. Generate or update a child theme from the Whisk starter. +3. Install or configure the chosen Emulsify component system, then move project components into that system's source structure. +4. Move only intentional template overrides into the child theme `templates` directory. +5. Rebuild child theme assets with Vite. +6. Confirm WordPress is activating the child theme, not the parent. +7. Run the validation commands from the parent repository root. + +## Template changes + +The parent theme now provides minimal Timber fallbacks for normal WordPress routes. Child themes should not copy every parent template. Add a child template only when the project needs to override or extend that fallback. + +When a child template extends the parent template with the same relative path, use the parent-only namespace: + +```twig +{% extends '@emulsify-tpl/page.twig' %} +``` + +Using `@templates/page.twig` from `whisk/templates/page.twig` would resolve back to the child file. + +## Block metadata changes + +ACF/Twig block metadata and native `block.json` files are discovered from built `dist/components` output. The child theme is scanned before the parent theme, and duplicate definitions are skipped instead of being registered twice. + +For ACF/Twig blocks, the final ACF block `name` is checked after defaults, metadata, and filters are merged. For native blocks, the `name` value in `block.json` is checked before registration. + +## Validation + +Run: + +```sh +npm run pr:check +npm run release:check +``` + +The full WordPress fixture smoke test needs WP-CLI and a database. If `wp` is not available locally, `release:check` reports that fixture as skipped. diff --git a/docs/wp-cli-child-theme-generation.md b/docs/wp-cli-child-theme-generation.md new file mode 100644 index 0000000..d2a105c --- /dev/null +++ b/docs/wp-cli-child-theme-generation.md @@ -0,0 +1,103 @@ +# WP-CLI child theme generation + +The parent theme includes a WP-CLI command for generating a project child theme from the bundled Whisk starter. + +## Basic usage + +```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 +``` + +## Options + +| Option | Purpose | +| --- | --- | +| `--machine-name=` | Override the generated slug used for paths and package metadata. | +| `--dry-run` | Show what would be created or changed without writing files. | +| `--force` | Replace an existing destination only when it already looks like an Emulsify-generated child theme. | +| `--activate` | Activate the generated child theme after creation. | + +## What the generator updates + +The generator copies `emulsify/whisk` to a sibling child theme directory and updates: + +- WordPress `Theme Name`. +- WordPress `Text Domain`. +- WordPress `Template`. +- `package.json` name. +- `project.emulsify.json` project name. +- `project.emulsify.json` machine name. +- `project.emulsify.json` generated source metadata. +- Visible Whisk starter labels where appropriate. +- Lowercase slug references where appropriate. + +The generator avoids broad blind string replacement. It targets known metadata files and starter labels. + +Ignored dependency, cache, Storybook, and Vite output directories are not copied. A generated child theme should install its own dependencies, install or configure the chosen Emulsify component system, and create its own `dist` output. + +## Force replacement safety + +`--force` refuses to delete an existing destination unless the directory has Emulsify-generated child theme markers: + +- `style.css` declares `Template: emulsify` or the selected parent slug. +- `project.emulsify.json` exists. +- `project.emulsify.json` declares `"platform": "wordpress"`. +- `project.emulsify.json` includes a project `machineName`. +- New generated child themes include `generatedFrom: "emulsify-wordpress"` and `generatedFromVersion`; `--force` refuses a destination that declares a different generated source. + +Use `--dry-run --force` to inspect replacement intent without deleting files. If the destination is an unrelated theme, remove or rename it manually before generating a child theme with the same machine name. + +## Upgrade and support diagnostics + +Generated child themes record their source in `project.emulsify.json`: + +```json +{ + "project": { + "platform": "wordpress", + "generatedFrom": "emulsify-wordpress", + "generatedFromVersion": "2.0.0" + } +} +``` + +Use these fields when diagnosing project lineage or planning starter upgrades. `generatedFrom` identifies the Emulsify WordPress starter lineage, while `generatedFromVersion` records the parent/starter release that generated or last regenerated the child theme. Older generated child themes may not have these fields; the generator still uses the existing WordPress platform and machine-name markers for compatibility. + +## Emulsify CLI starter hook + +Emulsify CLI uses the standalone starter repository at `https://github.com/emulsify-ds/emulsify-wordpress-starter`. That repository should be built from this branch's `whisk/` directory, which is the generated child theme layer rather than the parent runtime theme root. + +The starter includes `.cli/init.js` for CLI initialization. Current Emulsify CLI timing is: + +1. Clone the starter. +2. Write `project.emulsify.json`. +3. Run `npm install`. +4. Execute `.cli/init.js`. + +Because `npm install` runs before the hook, the hook updates package metadata and the generated `package-lock.json` when the lockfile exists. A pre-install CLI hook is not required for the current targeted metadata updates. + +The hook deliberately mirrors the parent WP-CLI generator's targeted updates: + +- `style.css` `Theme Name`, `Text Domain`, and `Template`. +- `package.json` name and generated package lockfile root name. +- `project.emulsify.json` project name, machine name, generated source metadata, and `"platform": "wordpress"`. +- Visible Whisk labels in known starter files. +- `templates/page.twig` class from `whisk-page` to the generated machine-name class. +- JSON pattern names from the `whisk/*` namespace to the generated machine-name namespace. + +The generated child theme must keep `Template: emulsify`; the parent theme remains installed separately as `emulsify`. + +## After generation + +Install dependencies and build assets in the generated child theme: + +```sh +cd web/app/themes/acme-site +npm install +npm run build +``` + +Activate the child theme for site work. The parent theme should stay installed as `emulsify`. diff --git a/footer.php b/footer.php deleted file mode 100644 index c00e749..0000000 --- a/footer.php +++ /dev/null @@ -1,21 +0,0 @@ - \ No newline at end of file diff --git a/images/icons/instagram.svg b/images/icons/instagram.svg deleted file mode 100644 index 0795a95..0000000 --- a/images/icons/instagram.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/images/icons/menu.svg b/images/icons/menu.svg deleted file mode 100755 index 7d083ab..0000000 --- a/images/icons/menu.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/images/icons/twitter.svg b/images/icons/twitter.svg deleted file mode 100755 index 59bab96..0000000 --- a/images/icons/twitter.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/images/logo.png b/images/logo.png deleted file mode 100644 index 720816b..0000000 Binary files a/images/logo.png and /dev/null differ diff --git a/inc/setup.php b/inc/setup.php deleted file mode 100644 index cf04299..0000000 --- a/inc/setup.php +++ /dev/null @@ -1,257 +0,0 @@ -

    Timber not activated. Make sure you activate the plugin in ' . esc_url( admin_url( 'plugins.php' ) ) . '

    '; - } - ); - - add_filter( - 'template_include', - function( $template ) { - return get_stylesheet_directory() . '/static/no-timber.html'; - } - ); - return; -} - -/** - * Sets the directories (inside your theme) to find .twig files - */ -Timber::$dirname = array( 'templates', 'views' ); - -/** - * By default, Timber does NOT autoescape values. Want to enable Twig's autoescape? - * No prob! Just set this value to true - */ -Timber::$autoescape = false; - - -/** - * We're going to configure our theme inside of a subclass of Timber\Site - * You can move this to its own file and include here via php's include("MySite.php") - */ -class StarterSite extends Timber\Site { - /** Add timber support. */ - public function __construct() { - add_action( 'after_setup_theme', array( $this, 'theme_supports' ) ); - add_filter( 'timber/context', array( $this, 'add_to_context' ) ); - add_filter( 'timber/twig', array( $this, 'add_to_twig' ) ); - add_action( 'init', array( $this, 'register_post_types' ) ); - add_action( 'init', array( $this, 'register_taxonomies' ) ); - parent::__construct(); - } - /** This is where you can register custom post types. */ - public function register_post_types() { - - } - /** This is where you can register custom taxonomies. */ - public function register_taxonomies() { - - } - - /** This is where you add some context - * - * @param string $context context['this'] Being the Twig's {{ this }}. - */ - public function add_to_context( $context ) { - $context['foo'] = 'bar'; - $context['stuff'] = 'I am a value set in your functions.php file'; - $context['notes'] = 'These values are available everytime you call Timber::context();'; - $context['menu'] = new Timber\Menu(); - $context['site'] = $this; - return $context; - } - - public function theme_supports() { - // Add default posts and comments RSS feed links to head. - add_theme_support( 'automatic-feed-links' ); - - /* - * Let WordPress manage the document title. - * By adding theme support, we declare that this theme does not use a - * hard-coded tag in the document head, and expect WordPress to - * provide it for us. - */ - add_theme_support( 'title-tag' ); - - /* - * Enable support for Post Thumbnails on posts and pages. - * - * @link https://developer.wordpress.org/themes/functionality/featured-images-post-thumbnails/ - */ - add_theme_support( 'post-thumbnails' ); - - /* - * Switch default core markup for search form, comment form, and comments - * to output valid HTML5. - */ - add_theme_support( - 'html5', - array( - 'comment-form', - 'comment-list', - 'gallery', - 'caption', - ) - ); - - /* - * Enable support for Post Formats. - * - * See: https://codex.wordpress.org/Post_Formats - */ - add_theme_support( - 'post-formats', - array( - 'aside', - 'image', - 'video', - 'quote', - 'link', - 'gallery', - 'audio', - ) - ); - - add_theme_support( 'menus' ); - } - - /** BEM function to pass in bem style classes. - * - */ - public function bem($context, $base_class, $modifiers = array(), $blockname = '', $extra = array()) { - $classes = []; - - // Add the ability to pass an object as the one and only argument. - if (is_object($base_class) || is_array($base_class)) { - $object = (object) $base_class; - unset($base_class); - $map = [ - 'block' => 'base_class', - 'element' => 'blockname', - 'modifiers' => 'modifiers', - 'extra' => 'extra', - ]; - foreach ($map as $object_key => $arg_key) { - if (isset($object->$object_key)) { - $$arg_key = $object->$object_key; - } - } - } - - // Ensure array arguments. - if (!empty($modifiers) && !is_array($modifiers)) { - $modifiers = [$modifiers]; - } - if (!is_array($extra)) { - $extra = [$extra]; - } - - // If using a blockname to override default class. - if ($blockname) { - // Set blockname class. - $classes[] = $blockname . '__' . $base_class; - // Set blockname--modifier classes for each modifier. - if (isset($modifiers) && is_array($modifiers)) { - foreach ($modifiers as $modifier) { - $classes[] = $blockname . '__' . $base_class . '--' . $modifier; - }; - } - } - // If not overriding base class. - else { - // Set base class. - $classes[] = $base_class; - // Set base--modifier class for each modifier. - if (isset($modifiers) && is_array($modifiers)) { - foreach ($modifiers as $modifier) { - $classes[] = $base_class . '--' . $modifier; - }; - } - } - // If extra non-BEM classes are added. - if (isset($extra) && is_array($extra)) { - foreach ($extra as $extra_class) { - $classes[] = $extra_class; - }; - } - $attributes = 'class="' . implode(' ', $classes) . '"'; - return $attributes; - } - - /** - * Add Attributes function to pass in multiple attributes including bem style classes. - */ - public function add_attributes( $context, $additional_attributes = [] ) { - $attributes = []; - - if ( ! empty( $additional_attributes ) ) { - foreach ( $additional_attributes as $key => $value ) { - // If there are multiple items in $value as array (e.g., class: ['one', 'two']). - if ( is_array( $value ) ) { - $attributes[] = ( $key . '="' . implode( ' ', $value ) . '"' ); - } else { - // Handle bem() output (pass in exactly the result). - if ( strpos( $value, '=' ) !== false ) { - $attributes[] = $value; - } else { - $attributes[] = ( $key . '="' . $value . '"' ); - } - } - } - } - - return implode( ' ', $attributes ); - } - - - /** This is where you can add your own functions to twig. - * - * @param string $twig get extension. - */ - public function add_to_twig( $twig ) { - $twig->addExtension( new Twig\Extension\StringLoaderExtension() ); - $twig->addFunction( new Twig_SimpleFunction('bem', array($this, 'bem'), array('needs_context' => true), array('is_safe' => array('html'))) ); - $twig->addFunction( new Twig_SimpleFunction('add_attributes', array($this, 'add_attributes'), array('needs_context' => true), array('is_safe' => array('html'))) ); - return $twig; - } - -} - -new StarterSite(); - -// Namespaces -add_filter('timber/loader/loader', function($loader){ - $loader->addPath(__DIR__ . "/../components/01-atoms", "atoms"); - $loader->addPath(__DIR__ . "/../components/02-molecules", "molecules"); - $loader->addPath(__DIR__ . "/../components/03-organisms", "organisms"); - $loader->addPath(__DIR__ . "/../components/04-templates", "templates"); - return $loader; -}); diff --git a/includes/Acf/LocalJson.php b/includes/Acf/LocalJson.php new file mode 100644 index 0000000..c26a733 --- /dev/null +++ b/includes/Acf/LocalJson.php @@ -0,0 +1,205 @@ +<?php +/** + * Configures optional ACF Local JSON paths. + * + * @package Emulsify + */ + +namespace Emulsify\Theme\Acf; + +/** + * ACF Local JSON integration. + */ +final class LocalJson { + + /** + * Registers ACF Local JSON hooks. + * + * @return void + */ + public function register(): void { + if ( ! $this->acf_available() ) { + // ACF is optional. Register no hooks until an ACF API marker exists so + // the parent theme can run cleanly on sites without the plugin. + return; + } + + add_filter( 'acf/settings/save_json', array( $this, 'save_path' ) ); + add_filter( 'acf/settings/load_json', array( $this, 'load_paths' ) ); + } + + /** + * Filters the ACF JSON save path. + * + * @param string $path Incoming ACF save path. + * @return string Filtered save path. + */ + public function save_path( string $path ): string { + if ( ! $this->enabled() ) { + return $path; + } + + $save_path = $this->configured_save_path(); + + return '' === $save_path ? $path : $save_path; + } + + /** + * Filters ACF JSON load paths. + * + * @param array $paths Incoming ACF load paths. + * @return array Filtered load paths. + */ + public function load_paths( array $paths ): array { + if ( ! $this->enabled() ) { + return $paths; + } + + $save_path = $this->configured_save_path(); + + if ( '' === $save_path ) { + // If the configured directory is missing, leave ACF's default Local JSON + // behavior untouched instead of creating paths implicitly. + return $paths; + } + + $load_paths = $paths; + + /** + * Filters whether ACF's default load path should be removed. + * + * Defaults to false so the parent theme does not remove ACF's built-in + * `acf-json` path unless a project explicitly opts into that behavior. + * + * @param bool $remove_default Whether to remove the first incoming load path. + * @param string $save_path Configured Emulsify ACF JSON save path. + * @param array $paths Incoming ACF load paths. + */ + $remove_default = (bool) apply_filters( 'emulsify_theme_acf_json_remove_default_load_path', false, $save_path, $paths ); + + if ( $remove_default && isset( $load_paths[0] ) ) { + unset( $load_paths[0] ); + } + + $load_paths[] = $save_path; + $load_paths = $this->normalize_paths( $load_paths ); + + /** + * Filters final ACF JSON load paths. + * + * @param array $load_paths Filtered ACF JSON load paths. + * @param string $save_path Configured Emulsify ACF JSON save path. + * @param array $paths Incoming ACF load paths. + */ + $filtered = apply_filters( 'emulsify_theme_acf_json_load_paths', $load_paths, $save_path, $paths ); + + return is_array( $filtered ) ? $this->normalize_paths( $filtered ) : $load_paths; + } + + /** + * Checks whether the Local JSON integration is enabled. + * + * @return bool TRUE when enabled. + */ + private function enabled(): bool { + $default_path = $this->default_path(); + $enabled = '' !== $default_path && is_dir( $default_path ); + + /** + * Filters whether Emulsify should configure ACF Local JSON paths. + * + * Defaults to true only when the active child theme has a config/acf-json + * directory. + * + * @param bool $enabled Whether ACF Local JSON path support is enabled. + * @param string $default_path Default active child theme ACF JSON path. + */ + return (bool) apply_filters( 'emulsify_theme_acf_json_enabled', $enabled, $default_path ); + } + + /** + * Gets the configured ACF JSON save path. + * + * @return string Existing save path, or an empty string. + */ + private function configured_save_path(): string { + $default_path = $this->default_path(); + $save_path = $default_path; + + /** + * Filters the ACF JSON save path. + * + * @param string $save_path ACF JSON save path. + * @param string $default_path Default active child theme ACF JSON path. + */ + $filtered = apply_filters( 'emulsify_theme_acf_json_save_path', $save_path, $default_path ); + + if ( is_scalar( $filtered ) ) { + $save_path = (string) $filtered; + } + + $save_path = rtrim( $save_path, '/\\' ); + + return '' !== $save_path && is_dir( $save_path ) ? $save_path : ''; + } + + /** + * Gets the active child theme ACF JSON path. + * + * @return string Default path, or an empty string when unavailable. + */ + private function default_path(): string { + if ( ! function_exists( 'get_stylesheet_directory' ) ) { + return ''; + } + + return rtrim( get_stylesheet_directory(), '/\\' ) . '/config/acf-json'; + } + + /** + * Normalizes and deduplicates filesystem paths. + * + * @param array $paths Path candidates. + * @return array Normalized paths. + */ + private function normalize_paths( array $paths ): array { + $normalized = array(); + $seen = array(); + + foreach ( $paths as $path ) { + if ( ! is_scalar( $path ) ) { + continue; + } + + $path = rtrim( (string) $path, '/\\' ); + + if ( '' === $path ) { + continue; + } + + $realpath = realpath( $path ); + $key = false !== $realpath ? $realpath : $path; + + if ( isset( $seen[ $key ] ) ) { + continue; + } + + $seen[ $key ] = true; + $normalized[] = $path; + } + + return $normalized; + } + + /** + * Checks whether ACF is available. + * + * @return bool TRUE when an ACF API marker exists. + */ + private function acf_available(): bool { + return function_exists( 'acf' ) + || function_exists( 'acf_get_setting' ) + || function_exists( 'acf_register_block_type' ) + || class_exists( '\ACF' ); + } +} diff --git a/includes/Blocks/AcfBlocks.php b/includes/Blocks/AcfBlocks.php new file mode 100644 index 0000000..ac4d94b --- /dev/null +++ b/includes/Blocks/AcfBlocks.php @@ -0,0 +1,696 @@ +<?php +/** + * Registers optional ACF blocks rendered by Timber. + * + * @package Emulsify + */ + +namespace Emulsify\Theme\Blocks; + +use Emulsify\Theme\Support\AssetEnqueuer; +use Emulsify\Theme\Support\AssetManifest; +use Emulsify\Theme\Support\AssetRecord; +use Emulsify\Theme\Support\Diagnostics; +use Emulsify\Theme\Support\FileDiscovery; + +/** + * ACF/Twig block integration. + */ +final class AcfBlocks { + + /** + * Component locator. + * + * @var ComponentLocator + */ + private $components; + + /** + * Asset manifest reader. + * + * @var AssetManifest + */ + private $manifest; + + /** + * Duplicate ACF block registrations skipped by this registrar. + * + * @var array + */ + private $skipped_duplicates = array(); + + /** + * Constructor. + * + * @param ComponentLocator|null $components Component locator. + * @param AssetManifest|null $manifest Asset manifest reader. + */ + public function __construct( ?ComponentLocator $components = null, ?AssetManifest $manifest = null ) { + $this->components = $components ?? new ComponentLocator(); + $this->manifest = $manifest ?? new AssetManifest(); + } + + /** + * Registers optional ACF hooks. + * + * @return void + */ + public function register(): void { + if ( ! function_exists( 'acf_register_block_type' ) ) { + // ACF block registration is optional; no hooks are registered when ACF is + // unavailable so non-ACF projects keep the same runtime behavior. + return; + } + + add_action( 'acf/init', array( $this, 'register_blocks' ) ); + } + + /** + * Registers component metadata as ACF blocks. + * + * @return void + */ + public function register_blocks(): void { + if ( ! function_exists( 'acf_register_block_type' ) ) { + return; + } + + $seen_names = array(); + + foreach ( $this->components->acf_components() as $component ) { + $metadata = $this->metadata( $component['metadata_path'] ); + + if ( null === $metadata ) { + continue; + } + + /** + * Filters ACF/Twig component metadata before block arguments are built. + * + * Metadata is read from the component *.component.json file. Defaults + * such as name, title, and render_callback are merged after this filter. + * + * @param array $metadata Component metadata. + * @param array $component Component discovery record. + */ + $filtered_metadata = apply_filters( 'emulsify_theme_acf_block_metadata', $metadata, $component ); + + if ( is_array( $filtered_metadata ) ) { + $metadata = $filtered_metadata; + } + + $args = $this->block_args( $component, $metadata ); + + /** + * Filters final ACF block registration arguments. + * + * Duplicate block names are checked after this filter so child themes + * and project plugins can intentionally alter the final ACF block name. + * + * @param array $args ACF block registration arguments. + * @param array $component Component discovery record. + * @param array $metadata Filtered component metadata. + */ + $filtered_args = apply_filters( 'emulsify_theme_acf_block_args', $args, $component, $metadata ); + + if ( is_array( $filtered_args ) ) { + $args = $filtered_args; + } + + $args['name'] = $this->normalize_block_name( + $args['name'] ?? '', + isset( $component['slug'] ) ? (string) $component['slug'] : 'block' + ); + $args = $this->with_asset_records( $args, $component, $metadata ); + $name = $this->block_name( $args ); + + if ( '' !== $name && isset( $seen_names[ $name ] ) ) { + // Duplicate names can happen after filters alter metadata. Register + // the first child-first record and report the skipped one in debug. + $this->skipped_duplicates[] = Diagnostics::duplicate_record( + 'acf_block_name', + $name, + $seen_names[ $name ], + $this->component_record( $component, $args ), + 'Duplicate ACF block name after metadata defaults were merged.' + ); + continue; + } + + if ( '' !== $name && $this->acf_block_registered( $name ) ) { + $this->skipped_duplicates[] = Diagnostics::duplicate_record( + 'acf_registered_block_name', + $name, + array( + 'name' => $name, + 'source' => 'existing', + ), + $this->component_record( $component, $args ), + 'ACF block name is already registered.' + ); + continue; + } + + if ( '' !== $name ) { + $seen_names[ $name ] = $this->component_record( $component, $args ); + } + + acf_register_block_type( $args ); + } + + Diagnostics::report_duplicates( + array_merge( + $this->components->skipped_duplicates( 'acf' ), + $this->skipped_duplicates + ), + function_exists( '__' ) ? __( 'Emulsify skipped duplicate ACF block definitions.', 'emulsify' ) : 'Emulsify skipped duplicate ACF block definitions.' + ); + } + + /** + * Gets duplicate ACF block records skipped by this registrar. + * + * @return array Skipped duplicate records. + */ + public function skipped_duplicates(): array { + return $this->skipped_duplicates; + } + + /** + * Renders an ACF block with Timber. + * + * @param array $block Block settings and attributes. + * @param string $content Inner block content. + * @param bool $is_preview TRUE during editor preview render. + * @param mixed $post_id Current post ID. + * @return void + */ + public function render_block( $block, $content = '', $is_preview = false, $post_id = 0 ): void { + if ( ! is_array( $block ) ) { + $this->render_error( __( 'Block rendering requires valid block metadata.', 'emulsify' ) ); + return; + } + + if ( ! class_exists( '\Timber\Timber' ) ) { + $this->render_error( __( 'Block rendering requires Timber.', 'emulsify' ) ); + return; + } + + $template = $this->template( $block ); + + if ( '' === $template ) { + $this->render_error( __( 'Block rendering error: no Twig template defined.', 'emulsify' ) ); + return; + } + + $context = \Timber\Timber::context(); + $context['block'] = $block; + $context['content'] = is_scalar( $content ) ? (string) $content : ''; + $context['fields'] = $this->fields(); + $context['is_preview'] = (bool) $is_preview; + + try { + // ACF expects render_callback output directly. Timber::render echoes the + // template, matching ACF's callback contract. + \Timber\Timber::render( $template, $context ); + } catch ( \Throwable $throwable ) { + $this->render_error( $throwable->getMessage() ); + } + } + + /** + * Reads component metadata. + * + * @param string $path Absolute metadata path. + * @return array|null Component metadata, or null when invalid. + */ + private function metadata( string $path ): ?array { + $contents = file_get_contents( $path ); + + if ( ! is_string( $contents ) ) { + return null; + } + + $metadata = json_decode( $contents, true ); + + return is_array( $metadata ) && JSON_ERROR_NONE === json_last_error() ? $metadata : null; + } + + /** + * Builds ACF block registration arguments. + * + * @param array $component Component record. + * @param array $metadata Component metadata. + * @return array ACF block arguments. + */ + private function block_args( array $component, array $metadata ): array { + $defaults = array( + 'name' => 'emulsify-' . $component['slug'], + 'title' => ucwords( str_replace( '-', ' ', $component['slug'] ) ), + 'mode' => 'preview', + ); + + $args = array_merge( $defaults, $metadata ); + $args['name'] = $this->normalize_block_name( $args['name'] ?? '', $component['slug'] ); + $args['render_callback'] = array( $this, 'render_block' ); + $args['twig_template'] = $component['template']; + $args['data'] = isset( $args['data'] ) && is_array( $args['data'] ) ? $args['data'] : array(); + $args['data']['twig_template'] = $component['template']; + + return $args; + } + + /** + * Adds a scoped asset callback when a component declares block assets. + * + * @param array $args ACF block registration arguments. + * @param array $component Component record. + * @param array $metadata Component metadata. + * @return array ACF block registration arguments. + */ + private function with_asset_records( array $args, array $component, array $metadata ): array { + $asset_records = $this->block_asset_records( $component, $metadata, $args ); + + /** + * Filters scoped ACF/Twig block asset records before registration. + * + * Records are grouped by frontend/editor context and css/js type. Return + * an empty record set to leave the block without scoped asset enqueueing. + * + * @param array $asset_records Scoped asset records. + * @param array $component Component discovery record. + * @param array $metadata Component metadata. + * @param array $args ACF block registration arguments. + */ + $filtered = apply_filters( 'emulsify_theme_acf_block_asset_records', $asset_records, $component, $metadata, $args ); + + if ( is_array( $filtered ) ) { + $asset_records = AssetRecord::normalize_context_records( $filtered ); + } + + if ( ! AssetRecord::has_context_records( $asset_records ) ) { + return $args; + } + + $existing_callback = isset( $args['enqueue_assets'] ) && is_callable( $args['enqueue_assets'] ) ? $args['enqueue_assets'] : null; + $args['enqueue_assets'] = $this->enqueue_assets_callback( $asset_records, $existing_callback, $this->block_name( $args ) ); + + return $args; + } + + /** + * Gets scoped block asset records. + * + * @param array $component Component record. + * @param array $metadata Component metadata. + * @param array $args ACF block registration arguments. + * @return array Scoped asset records. + */ + private function block_asset_records( array $component, array $metadata, array $args ): array { + $manifest_records = $this->manifest->scoped_asset_records( + $this->asset_identifiers( $component, $metadata, $args ) + ); + + if ( is_array( $manifest_records ) ) { + return AssetRecord::normalize_context_records( $manifest_records ); + } + + $metadata_records = $this->metadata_asset_records( $component, $metadata ); + + return is_array( $metadata_records ) ? $metadata_records : AssetRecord::empty_context_records(); + } + + /** + * Gets identifiers used to match manifest scoped assets. + * + * @param array $component Component record. + * @param array $metadata Component metadata. + * @param array $args ACF block registration arguments. + * @return array Asset identifiers. + */ + private function asset_identifiers( array $component, array $metadata, array $args ): array { + $identifiers = array( + $args['name'] ?? '', + 'acf/' . ( $args['name'] ?? '' ), + $component['relative'] ?? '', + $component['slug'] ?? '', + ); + + if ( isset( $metadata['name'] ) && is_scalar( $metadata['name'] ) ) { + $metadata_name = trim( (string) $metadata['name'] ); + $normalized = $this->normalize_block_name( $metadata_name, isset( $component['slug'] ) ? (string) $component['slug'] : 'block' ); + $identifiers[] = $metadata_name; + $identifiers[] = $normalized; + $identifiers[] = 'acf/' . $normalized; + } + + return array_values( + array_unique( + array_filter( + $identifiers, + static function ( $identifier ): bool { + return is_scalar( $identifier ) && '' !== trim( (string) $identifier ); + } + ) + ) + ); + } + + /** + * Gets component metadata asset records. + * + * @param array $component Component record. + * @param array $metadata Component metadata. + * @return array|null Scoped asset records, or null when undeclared. + */ + private function metadata_asset_records( array $component, array $metadata ): ?array { + if ( empty( $metadata['assets'] ) || ! is_array( $metadata['assets'] ) ) { + return null; + } + + $base_path = ! empty( $component['path'] ) && is_scalar( $component['path'] ) + ? rtrim( (string) $component['path'], '/\\' ) + : dirname( (string) ( $component['metadata_path'] ?? '' ) ); + $base_uri = $this->component_base_uri( $component, $base_path ); + + if ( '' === $base_uri || '' === $base_path ) { + return AssetRecord::empty_context_records(); + } + + return $this->metadata_context_records( $metadata['assets'], $base_path, $base_uri, $component ); + } + + /** + * Gets frontend/editor records from component metadata. + * + * @param array $assets Component metadata assets. + * @param string $base_path Component base path. + * @param string $base_uri Component base URI. + * @param array $component Component record. + * @return array Scoped asset records. + */ + private function metadata_context_records( array $assets, string $base_path, string $base_uri, array $component ): array { + $records = AssetRecord::empty_context_records(); + + if ( isset( $assets['frontend'] ) || isset( $assets['editor'] ) ) { + if ( isset( $assets['frontend'] ) && is_array( $assets['frontend'] ) ) { + $records['frontend'] = $this->metadata_type_records( $assets['frontend'], $base_path, $base_uri, $component ); + } + + if ( isset( $assets['editor'] ) && is_array( $assets['editor'] ) ) { + $records['editor'] = $this->metadata_type_records( $assets['editor'], $base_path, $base_uri, $component ); + } + + return $records; + } + + $records['frontend'] = $this->metadata_type_records( $assets, $base_path, $base_uri, $component ); + + return $records; + } + + /** + * Gets css/js records from component metadata. + * + * @param array $assets Metadata asset section. + * @param string $base_path Component base path. + * @param string $base_uri Component base URI. + * @param array $component Component record. + * @return array Asset records grouped by type. + */ + private function metadata_type_records( array $assets, string $base_path, string $base_uri, array $component ): array { + $records = array( + 'css' => array(), + 'js' => array(), + ); + + foreach ( array( 'css', 'js' ) as $type ) { + if ( empty( $assets[ $type ] ) || ! is_array( $assets[ $type ] ) ) { + continue; + } + + foreach ( $assets[ $type ] as $entry ) { + $record = $this->metadata_asset_record( $entry, $type, $base_path, $base_uri, $component ); + + if ( null !== $record ) { + $records[ $type ][] = $record; + } + } + } + + $records['css'] = FileDiscovery::sort_by_priority_and_relative( $records['css'] ); + $records['js'] = FileDiscovery::sort_by_priority_and_relative( $records['js'] ); + + return $records; + } + + /** + * Normalizes one component metadata asset record. + * + * @param mixed $entry Metadata asset entry. + * @param string $type Asset type. + * @param string $base_path Component base path. + * @param string $base_uri Component base URI. + * @param array $component Component record. + * @return array|null Asset record, or null when invalid. + */ + private function metadata_asset_record( $entry, string $type, string $base_path, string $base_uri, array $component ): ?array { + $data = is_array( $entry ) ? $entry : array( 'path' => $entry ); + $relative = AssetRecord::entry_path( $data ); + + if ( '' === $relative || strtolower( pathinfo( $relative, PATHINFO_EXTENSION ) ) !== $type ) { + return null; + } + + $path = $base_path . '/' . $relative; + + if ( ! is_readable( $path ) ) { + return null; + } + + return array( + 'path' => $path, + 'priority' => 0, + 'relative' => AssetRecord::entry_relative( $data, $relative ), + 'uri' => rtrim( $base_uri, '/' ) . '/' . $relative, + 'version' => AssetRecord::entry_version( $data, $path ), + 'dependencies' => AssetRecord::entry_dependencies( $data ), + 'module' => AssetRecord::entry_module( $data ), + 'source' => isset( $component['source'] ) ? (string) $component['source'] : '', + ); + } + + /** + * Builds a component base URI from discovery metadata. + * + * @param array $component Component record. + * @param string $base_path Component base path. + * @return string Component base URI, or empty string when unavailable. + */ + private function component_base_uri( array $component, string $base_path ): string { + if ( empty( $component['root_uri'] ) || empty( $component['root_path'] ) ) { + return ''; + } + + $relative = FileDiscovery::relative_path( (string) $component['root_path'], $base_path ); + + return rtrim( (string) $component['root_uri'], '/' ) . ( '' === $relative ? '' : '/' . $relative ); + } + + /** + * Builds the ACF enqueue callback for scoped block assets. + * + * @param array $asset_records Scoped asset records. + * @param callable|null $existing_callback Existing enqueue callback. + * @param string $block_name ACF block name. + * @return callable Enqueue callback. + */ + private function enqueue_assets_callback( array $asset_records, ?callable $existing_callback, string $block_name ): callable { + return function ( ...$callback_args ) use ( $asset_records, $existing_callback, $block_name ): void { + if ( is_callable( $existing_callback ) ) { + call_user_func_array( $existing_callback, $callback_args ); + } + + $this->enqueue_asset_records( $asset_records['frontend'] ?? array(), $block_name, 'frontend' ); + + if ( $this->is_editor_context() ) { + $this->enqueue_asset_records( $asset_records['editor'] ?? array(), $block_name, 'editor' ); + } + }; + } + + /** + * Enqueues a grouped asset record set. + * + * @param array $records Asset records grouped by css/js. + * @param string $block_name ACF block name. + * @param string $context Asset context. + * @return void + */ + private function enqueue_asset_records( array $records, string $block_name, string $context ): void { + foreach ( $records['css'] ?? array() as $record ) { + $this->enqueue_style_record( $record, $block_name, $context ); + } + + foreach ( $records['js'] ?? array() as $record ) { + $this->enqueue_script_record( $record, $block_name, $context ); + } + } + + /** + * Enqueues a style record. + * + * @param array $record Asset record. + * @param string $block_name ACF block name. + * @param string $context Asset context. + * @return void + */ + private function enqueue_style_record( array $record, string $block_name, string $context ): void { + AssetEnqueuer::enqueue_style( 'emulsify-acf-' . $context . '-' . $block_name, $record ); + } + + /** + * Enqueues a script record. + * + * @param array $record Asset record. + * @param string $block_name ACF block name. + * @param string $context Asset context. + * @return void + */ + private function enqueue_script_record( array $record, string $block_name, string $context ): void { + AssetEnqueuer::enqueue_script( 'emulsify-acf-' . $context . '-' . $block_name, $record ); + } + + /** + * Checks whether the current request is an editor/admin context. + * + * @return bool TRUE when editor/admin-only assets should load. + */ + private function is_editor_context(): bool { + return function_exists( 'is_admin' ) && is_admin(); + } + + /** + * Normalizes an ACF PHP block name to an ACF-safe un-namespaced slug. + * + * ACF's PHP registration API expects names like "testimonial"; WordPress + * exposes the final editor block as "acf/testimonial". + * + * @param mixed $name Candidate ACF block name. + * @param string $fallback Component slug fallback. + * @return string ACF-safe block name. + */ + private function normalize_block_name( $name, string $fallback ): string { + $candidate = is_scalar( $name ) ? (string) $name : ''; + + if ( '' === trim( $candidate ) ) { + $candidate = 'emulsify-' . $fallback; + } + + $normalized = strtolower( trim( $candidate ) ); + $normalized = preg_replace( '/[^a-z0-9-]+/', '-', $normalized ); + $normalized = trim( (string) $normalized, '-' ); + + if ( '' === $normalized ) { + $normalized = 'emulsify-' . $fallback; + $normalized = preg_replace( '/[^a-z0-9-]+/', '-', strtolower( $normalized ) ); + $normalized = trim( (string) $normalized, '-' ); + } + + if ( '' === $normalized || ! preg_match( '/^[a-z]/', $normalized ) ) { + $normalized = 'emulsify-' . $normalized; + } + + return $normalized; + } + + /** + * Gets a final ACF block name from registration arguments. + * + * @param array $args ACF block registration arguments. + * @return string Block name, or an empty string when unavailable. + */ + private function block_name( array $args ): string { + return ! empty( $args['name'] ) && is_string( $args['name'] ) ? trim( $args['name'] ) : ''; + } + + /** + * Checks whether ACF already knows about a block name. + * + * @param string $name ACF block name. + * @return bool TRUE when the block is already registered. + */ + private function acf_block_registered( string $name ): bool { + return function_exists( 'acf_get_block_type' ) && is_array( acf_get_block_type( $name ) ); + } + + /** + * Builds a debug record for an ACF component registration. + * + * @param array $component Component record. + * @param array $args ACF block registration arguments. + * @return array Debug record. + */ + private function component_record( array $component, array $args ): array { + return array( + 'name' => $this->block_name( $args ), + 'relative' => isset( $component['relative'] ) ? $component['relative'] : '', + 'source' => isset( $component['source'] ) ? $component['source'] : '', + 'metadata_path' => isset( $component['metadata_path'] ) ? $component['metadata_path'] : '', + 'template' => isset( $component['template'] ) ? $component['template'] : '', + ); + } + + /** + * Gets ACF field values for the block being rendered. + * + * @return array ACF field values. + */ + private function fields(): array { + if ( ! function_exists( 'get_fields' ) ) { + return array(); + } + + $fields = get_fields(); + + return is_array( $fields ) ? $fields : array(); + } + + /** + * Gets the block Twig template. + * + * @param array $block Block settings and attributes. + * @return string Template path. + */ + private function template( array $block ): string { + if ( ! empty( $block['data']['twig_template'] ) && is_string( $block['data']['twig_template'] ) ) { + return $block['data']['twig_template']; + } + + if ( ! empty( $block['twig_template'] ) && is_string( $block['twig_template'] ) ) { + return $block['twig_template']; + } + + return ''; + } + + /** + * Renders an editor-only block error. + * + * @param string $message Error message. + * @return void + */ + private function render_error( string $message ): void { + if ( function_exists( 'current_user_can' ) && ! current_user_can( 'edit_posts' ) ) { + // Block errors are authoring diagnostics. Avoid showing implementation + // details to normal frontend visitors. + return; + } + + printf( + '<p><strong>%s</strong> %s</p>', + esc_html__( 'Emulsify block error:', 'emulsify' ), + esc_html( $message ) + ); + } +} diff --git a/includes/Blocks/ComponentLocator.php b/includes/Blocks/ComponentLocator.php new file mode 100644 index 0000000..b14e608 --- /dev/null +++ b/includes/Blocks/ComponentLocator.php @@ -0,0 +1,779 @@ +<?php +/** + * Locates built component block artifacts. + * + * @package Emulsify + */ + +namespace Emulsify\Theme\Blocks; + +use Emulsify\Theme\Support\AssetRecord; +use Emulsify\Theme\Support\Diagnostics; +use Emulsify\Theme\Support\FileDiscovery; + +/** + * Finds component metadata in child and parent theme build output. + * + * Discovery is memoized on this object for the lifetime of the current PHP + * request. The registry shares one locator between ACF/Twig and native block + * registration, so both paths reuse the same deterministic child-first file + * index. Persistent caching defaults to on outside active development. + */ +final class ComponentLocator { + + /** + * Theme-relative component build directory. + * + * @var string + */ + private const COMPONENTS_DIRECTORY = 'dist/components'; + + /** + * Theme-relative built asset manifest path. + * + * @var string + */ + private const DEFAULT_MANIFEST_PATH = 'dist/emulsify-assets.json'; + + /** + * Transient key prefix for optional persistent discovery caching. + * + * @var string + */ + private const CACHE_TRANSIENT_PREFIX = 'emulsify_component_discovery_'; + + /** + * Default persistent cache lifetime in seconds. + * + * @var int + */ + private const DEFAULT_CACHE_TTL = 86400; + + /** + * Memoized component root records. + * + * @var array|null + */ + private $component_roots; + + /** + * Memoized component file records. + * + * @var array|null + */ + private $component_files; + + /** + * Memoized ACF/Twig component records. + * + * @var array|null + */ + private $acf_components; + + /** + * Memoized native block directory records. + * + * @var array|null + */ + private $native_block_directories; + + /** + * Duplicate component records skipped during discovery. + * + * @var array + */ + private $skipped_duplicates = array(); + + /** + * Clears the optional persistent discovery cache for the current theme state. + * + * @return bool TRUE when WordPress deleted a transient, otherwise false. + */ + public static function clear_discovery_cache(): bool { + return ( new self() )->delete_component_files_cache(); + } + + /** + * Gets components that can be registered as ACF/Twig blocks. + * + * @return array Component records. + */ + public function acf_components(): array { + if ( is_array( $this->acf_components ) ) { + return $this->acf_components; + } + + $components = array(); + $seen_paths = array(); + $seen_slugs = array(); + + foreach ( $this->component_files() as $file ) { + $path = $file['path']; + + if ( ! preg_match( '/\.component\.json$/', basename( $path ) ) ) { + continue; + } + + $directory = dirname( $path ); + $relative = FileDiscovery::relative_path( $file['root_path'], $directory ); + $key = '' === $relative ? '.' : $relative; + $record = $this->component_record( $file, $directory, $relative, $path ); + + if ( isset( $seen_paths[ $key ] ) ) { + $this->skipped_duplicates[] = Diagnostics::duplicate_record( + 'acf_component_path', + $key, + $seen_paths[ $key ], + $record, + 'Duplicate ACF/Twig component path.' + ); + continue; + } + + $seen_paths[ $key ] = $record; + $template = $this->twig_template( $file['root_path'], $directory, $path ); + + if ( '' === $template ) { + // A metadata file without a matching Twig template is not a renderable + // ACF/Twig component; native block.json registration is handled separately. + continue; + } + + $slug = $this->slug( $relative, $path ); + + if ( isset( $seen_slugs[ $slug ] ) ) { + $this->skipped_duplicates[] = Diagnostics::duplicate_record( + 'acf_component_slug', + $slug, + $seen_slugs[ $slug ], + $record, + 'Duplicate ACF/Twig component slug.' + ); + continue; + } + + $seen_slugs[ $slug ] = $record; + + $components[] = array( + 'path' => $directory, + 'metadata_path' => $path, + 'relative' => $relative, + 'root_path' => $file['root_path'], + 'root_uri' => $file['root_uri'] ?? '', + 'slug' => $slug, + 'source' => $file['root_source'], + 'template' => $template, + ); + } + + $this->acf_components = $components; + + return $this->acf_components; + } + + /** + * Gets component directories that contain native block metadata. + * + * @return array Component directory records. + */ + public function native_block_directories(): array { + if ( is_array( $this->native_block_directories ) ) { + return $this->native_block_directories; + } + + $directories = array(); + $seen_names = array(); + $seen_paths = array(); + + foreach ( $this->component_files() as $file ) { + $path = $file['path']; + + if ( 'block.json' !== basename( $path ) ) { + continue; + } + + $directory = dirname( $path ); + $relative = FileDiscovery::relative_path( $file['root_path'], $directory ); + $key = '' === $relative ? '.' : $relative; + $name = $this->native_block_name( $path ); + $record = $this->component_record( $file, $directory, $relative, $path ); + + if ( '' !== $name ) { + $record['name'] = $name; + } + + if ( isset( $seen_paths[ $key ] ) ) { + $this->skipped_duplicates[] = Diagnostics::duplicate_record( + 'native_component_path', + $key, + $seen_paths[ $key ], + $record, + 'Duplicate native block component path.' + ); + continue; + } + + if ( '' !== $name && isset( $seen_names[ $name ] ) ) { + $this->skipped_duplicates[] = Diagnostics::duplicate_record( + 'native_block_name', + $name, + $seen_names[ $name ], + $record, + 'Duplicate native block name.' + ); + continue; + } + + $seen_paths[ $key ] = $record; + + if ( '' !== $name ) { + $seen_names[ $name ] = $record; + } + + $directories[] = array( + 'path' => $directory, + 'relative' => $relative, + 'root_path' => $file['root_path'], + 'root_uri' => $file['root_uri'] ?? '', + 'source' => $file['root_source'], + 'metadata_path' => $path, + 'name' => $name, + ); + } + + $this->native_block_directories = $directories; + + return $this->native_block_directories; + } + + /** + * Gets duplicate records skipped during discovery. + * + * @param string|null $type_prefix Optional duplicate type prefix filter. + * @return array Skipped duplicate records. + */ + public function skipped_duplicates( ?string $type_prefix = null ): array { + if ( null === $type_prefix ) { + return $this->skipped_duplicates; + } + + return array_values( + array_filter( + $this->skipped_duplicates, + static function ( array $duplicate ) use ( $type_prefix ): bool { + return isset( $duplicate['type'] ) && 0 === strpos( $duplicate['type'], $type_prefix ); + } + ) + ); + } + + /** + * Gets all component files from child and parent roots. + * + * The file index is built once per locator instance. The shared registry + * locator means ACF/Twig metadata discovery and native block discovery reuse + * the same recursive filesystem scan during a request. + * + * @return array Component file records. + */ + private function component_files(): array { + if ( is_array( $this->component_files ) ) { + return $this->component_files; + } + + $cached = $this->cached_component_files(); + + if ( is_array( $cached ) ) { + $this->component_files = $cached; + return $this->component_files; + } + + $this->component_files = FileDiscovery::file_records( $this->component_roots() ); + $this->cache_component_files( $this->component_files ); + + return $this->component_files; + } + + /** + * Gets cached component file records when optional caching is enabled. + * + * @return array|null Component file records, or null when scanning should run. + */ + private function cached_component_files(): ?array { + if ( ! $this->persistent_cache_enabled() || ! function_exists( 'get_transient' ) ) { + return null; + } + + $cached = get_transient( $this->component_files_cache_key() ); + + if ( ! is_array( $cached ) || ! isset( $cached['files'] ) || ! is_array( $cached['files'] ) ) { + return null; + } + + return $this->normalize_cached_component_files( $cached['files'] ); + } + + /** + * Stores component file records in the optional persistent cache. + * + * @param array $files Component file records. + * @return void + */ + private function cache_component_files( array $files ): void { + if ( ! $this->persistent_cache_enabled() || ! function_exists( 'set_transient' ) ) { + return; + } + + set_transient( + $this->component_files_cache_key(), + array( + 'files' => $files, + ), + $this->component_files_cache_ttl() + ); + } + + /** + * Deletes the optional persistent component file cache. + * + * @return bool TRUE when WordPress deleted a transient, otherwise false. + */ + private function delete_component_files_cache(): bool { + if ( ! function_exists( 'delete_transient' ) ) { + return false; + } + + return (bool) delete_transient( $this->component_files_cache_key() ); + } + + /** + * Checks whether optional persistent discovery caching is enabled. + * + * @return bool TRUE when persistent caching should be used. + */ + private function persistent_cache_enabled(): bool { + $enabled = ! $this->is_development_environment(); + + /** + * Filters whether component discovery should use persistent caching. + * + * The default is true in production and staging environments, and false + * in local, development, or WP_DEBUG environments. Return a boolean value + * to override the default either way. + * + * @param bool $enabled Whether persistent caching is enabled. + * @param ComponentLocator $locator Component locator instance. + * @param string $environment Current WordPress environment type. + */ + $filtered = apply_filters( 'emulsify_theme_component_discovery_cache_enabled', $enabled, $this, $this->environment_type() ); + + return (bool) $filtered; + } + + /** + * Builds the transient cache key for the current theme state. + * + * @return string Transient key. + */ + private function component_files_cache_key(): string { + $stylesheet = $this->stylesheet(); + $template = $this->template(); + $stylesheet_directory = function_exists( 'get_stylesheet_directory' ) ? get_stylesheet_directory() : ''; + $template_directory = function_exists( 'get_template_directory' ) ? get_template_directory() : ''; + $key_parts = array( + 'stylesheet' => $stylesheet, + 'template' => $template, + 'stylesheet_version' => $this->theme_version( $stylesheet, $stylesheet_directory ), + 'template_version' => $this->theme_version( $template, $template_directory ), + 'environment' => $this->environment_type(), + 'manifest' => $this->manifest_file_signature(), + ); + + /** + * Filters the component discovery persistent cache key parts. + * + * Projects that alter component roots dynamically can add their own + * version token here, or change it to invalidate cached discovery. + * + * @param array $key_parts Cache key parts. + * @param ComponentLocator $locator Component locator instance. + */ + $filtered = apply_filters( 'emulsify_theme_component_discovery_cache_key_parts', $key_parts, $this ); + + if ( is_array( $filtered ) ) { + $key_parts = $filtered; + } + + $encoded = function_exists( 'wp_json_encode' ) ? wp_json_encode( $key_parts ) : json_encode( $key_parts ); + + if ( ! is_string( $encoded ) ) { + $encoded = serialize( $key_parts ); + } + + return self::CACHE_TRANSIENT_PREFIX . md5( $encoded ); + } + + /** + * Gets the optional persistent cache lifetime. + * + * @return int Cache lifetime in seconds. + */ + private function component_files_cache_ttl(): int { + $ttl = defined( 'DAY_IN_SECONDS' ) ? (int) DAY_IN_SECONDS : self::DEFAULT_CACHE_TTL; + + /** + * Filters the component discovery persistent cache lifetime. + * + * @param int $ttl Cache lifetime in seconds. + * @param ComponentLocator $locator Component locator instance. + */ + $filtered = apply_filters( 'emulsify_theme_component_discovery_cache_ttl', $ttl, $this ); + + return is_numeric( $filtered ) ? max( 0, (int) $filtered ) : $ttl; + } + + /** + * Normalizes cached file records and rejects invalid cache payloads. + * + * @param array $files Cached component file records. + * @return array|null Normalized records, or null for invalid cache data. + */ + private function normalize_cached_component_files( array $files ): ?array { + $normalized = array(); + + foreach ( $files as $file ) { + if ( + ! is_array( $file ) + || empty( $file['path'] ) + || empty( $file['relative'] ) + || empty( $file['root_path'] ) + || empty( $file['root_source'] ) + || ! is_scalar( $file['path'] ) + || ! is_scalar( $file['relative'] ) + || ! is_scalar( $file['root_path'] ) + || ! is_scalar( $file['root_source'] ) + ) { + return null; + } + + $record = array( + 'path' => (string) $file['path'], + 'priority' => isset( $file['priority'] ) ? (int) $file['priority'] : 0, + 'relative' => (string) $file['relative'], + 'root_path' => (string) $file['root_path'], + 'root_source' => (string) $file['root_source'], + ); + + if ( isset( $file['root_uri'] ) && is_scalar( $file['root_uri'] ) ) { + $record['root_uri'] = (string) $file['root_uri']; + } + + $normalized[] = $record; + } + + return $normalized; + } + + /** + * Checks whether active development should keep the default cache disabled. + * + * @return bool TRUE when the environment looks like active development. + */ + private function is_development_environment(): bool { + if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { + return true; + } + + return in_array( $this->environment_type(), array( 'local', 'development' ), true ); + } + + /** + * Gets the current WordPress environment type. + * + * @return string Environment type. + */ + private function environment_type(): string { + if ( function_exists( 'wp_get_environment_type' ) ) { + $environment = wp_get_environment_type(); + + if ( is_scalar( $environment ) && '' !== trim( (string) $environment ) ) { + return (string) $environment; + } + } + + return defined( 'WP_ENVIRONMENT_TYPE' ) && is_scalar( WP_ENVIRONMENT_TYPE ) ? (string) WP_ENVIRONMENT_TYPE : 'production'; + } + + /** + * Gets the active child stylesheet slug. + * + * @return string Stylesheet slug. + */ + private function stylesheet(): string { + if ( function_exists( 'get_stylesheet' ) ) { + $stylesheet = get_stylesheet(); + + if ( is_scalar( $stylesheet ) && '' !== trim( (string) $stylesheet ) ) { + return (string) $stylesheet; + } + } + + return function_exists( 'get_stylesheet_directory' ) ? basename( get_stylesheet_directory() ) : ''; + } + + /** + * Gets the active parent template slug. + * + * @return string Template slug. + */ + private function template(): string { + if ( function_exists( 'get_template' ) ) { + $template = get_template(); + + if ( is_scalar( $template ) && '' !== trim( (string) $template ) ) { + return (string) $template; + } + } + + return function_exists( 'get_template_directory' ) ? basename( get_template_directory() ) : ''; + } + + /** + * Gets a theme version from WordPress metadata or style.css. + * + * @param string $stylesheet Theme stylesheet slug. + * @param string $directory Theme directory. + * @return string Theme version. + */ + private function theme_version( string $stylesheet, string $directory ): string { + if ( '' !== $stylesheet && function_exists( 'wp_get_theme' ) ) { + $theme = wp_get_theme( $stylesheet ); + + if ( is_object( $theme ) && method_exists( $theme, 'get' ) ) { + $version = $theme->get( 'Version' ); + + if ( is_scalar( $version ) && '' !== trim( (string) $version ) ) { + return (string) $version; + } + } + } + + return $this->style_css_version( $directory ); + } + + /** + * Reads a Version header from a theme style.css file. + * + * @param string $directory Theme directory. + * @return string Version header, or empty string. + */ + private function style_css_version( string $directory ): string { + $path = rtrim( $directory, '/\\' ) . '/style.css'; + + if ( '' === $directory || ! is_readable( $path ) ) { + return ''; + } + + $contents = file_get_contents( $path ); + + if ( ! is_string( $contents ) || ! preg_match( '/^\s*\*?\s*Version:\s*(.+)$/mi', $contents, $matches ) ) { + return ''; + } + + return trim( $matches[1] ); + } + + /** + * Gets a child/parent manifest file signature for cache invalidation. + * + * @return string Manifest signature. + */ + private function manifest_file_signature(): string { + $relative_path = $this->manifest_relative_path(); + + if ( '' === $relative_path ) { + return ''; + } + + $signatures = array(); + $themes = array( + 'child' => function_exists( 'get_stylesheet_directory' ) ? get_stylesheet_directory() : '', + 'parent' => function_exists( 'get_template_directory' ) ? get_template_directory() : '', + ); + + foreach ( $themes as $source => $directory ) { + if ( ! is_scalar( $directory ) || '' === trim( (string) $directory ) ) { + continue; + } + + $path = rtrim( (string) $directory, '/\\' ) . '/' . $relative_path; + + if ( ! is_readable( $path ) ) { + continue; + } + + $mtime = filemtime( $path ); + $signatures[] = $source . ':' . $relative_path . ':' . ( false === $mtime ? '' : (string) $mtime ); + } + + return implode( '|', $signatures ); + } + + /** + * Gets the theme-relative asset manifest path. + * + * @return string Manifest path, or empty string when disabled. + */ + private function manifest_relative_path(): string { + $relative_path = self::DEFAULT_MANIFEST_PATH; + + /** + * Filters the theme-relative asset manifest path. + * + * This mirrors asset loading so changing the manifest path also changes + * the optional component discovery cache key. + * + * @param string $relative_path Theme-relative manifest path. + */ + $filtered = apply_filters( 'emulsify_theme_asset_manifest_path', $relative_path ); + + if ( ! is_scalar( $filtered ) ) { + return ''; + } + + return AssetRecord::normalize_relative_path( (string) $filtered ); + } + + /** + * Gets child theme component roots first, then parent theme fallbacks. + * + * Matching relative component directories in the child theme win because + * child roots are indexed first and later parent records with the same + * relative path are skipped by the discovery methods. + * + * @return array Component root records. + */ + private function component_roots(): array { + if ( is_array( $this->component_roots ) ) { + return $this->component_roots; + } + + $candidates = FileDiscovery::theme_roots( self::COMPONENTS_DIRECTORY, true ); + + /** + * Filters built component discovery roots before scanning. + * + * Root records should include an absolute path to a dist/components + * directory and an optional source label. The default order is child + * theme first, then parent theme fallback. + * + * @param array $candidates Component root records. + */ + $filtered = apply_filters( 'emulsify_theme_component_roots', $candidates ); + + if ( is_array( $filtered ) ) { + $candidates = $filtered; + } + + $this->component_roots = FileDiscovery::normalize_roots( + $candidates, + array( + 'default_source' => 'filtered', + ) + ); + + return $this->component_roots; + } + + /** + * Builds a debug record for a component artifact. + * + * @param array $file Component file record. + * @param string $directory Absolute component directory path. + * @param string $relative Component-relative directory path. + * @param string $metadata_path Absolute metadata path. + * @return array Component debug record. + */ + private function component_record( array $file, string $directory, string $relative, string $metadata_path ): array { + return array( + 'path' => $directory, + 'relative' => $relative, + 'root_path' => $file['root_path'], + 'root_uri' => $file['root_uri'] ?? '', + 'source' => $file['root_source'], + 'metadata_path' => $metadata_path, + ); + } + + /** + * Gets the declared block name from a native block.json file. + * + * @param string $path Absolute block.json path. + * @return string Native block name, or an empty string when unavailable. + */ + private function native_block_name( string $path ): string { + $contents = file_get_contents( $path ); + + if ( ! is_string( $contents ) ) { + return ''; + } + + $metadata = json_decode( $contents, true ); + + if ( ! is_array( $metadata ) || JSON_ERROR_NONE !== json_last_error() || empty( $metadata['name'] ) || ! is_string( $metadata['name'] ) ) { + return ''; + } + + return trim( $metadata['name'] ); + } + + /** + * Finds the Twig template for a component metadata file. + * + * @param string $root_path Absolute component root path. + * @param string $directory Absolute component directory path. + * @param string $metadata_path Absolute component metadata path. + * @return string Theme-relative Twig template path. + */ + private function twig_template( string $root_path, string $directory, string $metadata_path ): string { + $metadata_slug = preg_replace( '/\.component\.json$/', '', basename( $metadata_path ) ); + $candidates = array( + $directory . '/' . $metadata_slug . '.twig', + $directory . '/' . basename( $directory ) . '.twig', + ); + + foreach ( array_unique( $candidates ) as $candidate ) { + if ( is_readable( $candidate ) ) { + return $this->theme_relative_path( $root_path, $candidate ); + } + } + + return ''; + } + + /** + * Builds a component slug from its path. + * + * @param string $relative Component-relative directory path. + * @param string $metadata_path Absolute component metadata path. + * @return string Component slug. + */ + private function slug( string $relative, string $metadata_path ): string { + $slug = '' === $relative ? preg_replace( '/\.component\.json$/', '', basename( $metadata_path ) ) : $relative; + + return sanitize_title( str_replace( '/', '-', (string) $slug ) ); + } + + /** + * Builds a theme-relative path for Timber rendering. + * + * @param string $root_path Absolute component root path. + * @param string $path Absolute template path. + * @return string Theme-relative template path. + */ + private function theme_relative_path( string $root_path, string $path ): string { + $relative = FileDiscovery::relative_path( $root_path, $path ); + + return self::COMPONENTS_DIRECTORY . '/' . $relative; + } +} diff --git a/includes/Blocks/CoreBlockTwigRenderer.php b/includes/Blocks/CoreBlockTwigRenderer.php new file mode 100644 index 0000000..711fb96 --- /dev/null +++ b/includes/Blocks/CoreBlockTwigRenderer.php @@ -0,0 +1,476 @@ +<?php +/** + * Optionally renders mapped WordPress blocks through Twig templates. + * + * @package Emulsify + */ + +namespace Emulsify\Theme\Blocks; + +/** + * Opt-in core block to Twig rendering bridge. + */ +final class CoreBlockTwigRenderer { + + /** + * Registers block rendering hooks when explicitly enabled. + * + * @return void + */ + public function register(): void { + if ( ! $this->enabled() ) { + return; + } + + add_filter( 'render_block', array( $this, 'render_block' ), 20, 3 ); + } + + /** + * Renders a mapped block with Twig, falling back to WordPress output. + * + * @param string $block_content Rendered WordPress block content. + * @param array $block Parsed block metadata. + * @param mixed $instance WP_Block instance when available. + * @return string Rendered block content. + */ + public function render_block( string $block_content, array $block, $instance = null ): string { + $block_name = $this->block_name( $block ); + + if ( '' === $block_name ) { + return $block_content; + } + + $template = $this->template_for_block( $block_name, $block ); + + if ( '' === $template ) { + // Unmapped blocks keep WordPress' original HTML, which protects block + // validation and frontend output unless a project opts in block-by-block. + return $block_content; + } + + if ( ! class_exists( '\Timber\Timber' ) ) { + return $this->render_error( $block_content, $this->translate( 'Timber is not available for mapped block rendering.', 'emulsify' ), $block, $template ); + } + + try { + $context = $this->context( $block, $block_content, $instance, $template ); + $html = \Timber\Timber::compile( $template, $context ); + } catch ( \Throwable $throwable ) { + // Rendering failures should not blank content. Editors get diagnostics + // when allowed; visitors continue to receive WordPress' rendered block. + return $this->render_error( $block_content, $throwable->getMessage(), $block, $template ); + } + + if ( ! is_string( $html ) || '' === $html ) { + return $block_content; + } + + return $this->cleanup_classes_enabled( $block, $template ) + ? $this->cleanup_classes( $html, $block ) + : $html; + } + + /** + * Checks whether Twig rendering is enabled. + * + * @return bool TRUE when enabled. + */ + private function enabled(): bool { + /** + * Filters whether mapped core block Twig rendering should be enabled. + * + * Defaults to false so the parent theme never changes frontend block + * output unless a child theme or project plugin opts in. + * + * @param bool $enabled Whether mapped block Twig rendering is enabled. + */ + return (bool) apply_filters( 'emulsify_theme_core_block_twig_rendering_enabled', false ); + } + + /** + * Builds Twig context for a mapped block. + * + * @param array $block Parsed block metadata. + * @param string $block_content Rendered WordPress block content. + * @param mixed $instance WP_Block instance when available. + * @param string $template Twig template being rendered. + * @return array Twig context. + */ + private function context( array $block, string $block_content, $instance, string $template ): array { + $context = array(); + + if ( class_exists( '\Timber\Timber' ) ) { + $timber_context = \Timber\Timber::context(); + $context = is_array( $timber_context ) ? $timber_context : array(); + } + + $attributes = isset( $block['attrs'] ) && is_array( $block['attrs'] ) ? $block['attrs'] : array(); + + // Keep WordPress' original rendered content available to Twig templates so + // mapped blocks can wrap or progressively replace markup instead of being + // forced into an all-or-nothing rewrite. + $context['block'] = $block; + $context['block_metadata'] = $block; + $context['block_name'] = $this->block_name( $block ); + $context['attributes'] = $attributes; + $context['content'] = $block_content; + $context['inner_content'] = $this->inner_content( $block, $block_content ); + $context['inner_blocks'] = isset( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ? $block['innerBlocks'] : array(); + $context['wp_block'] = $instance; + $context['twig_template'] = $template; + + /** + * Filters Twig context for mapped block rendering. + * + * @param array $context Twig context. + * @param array $block Parsed block metadata. + * @param string $block_content Rendered WordPress block content. + * @param string $template Twig template being rendered. + * @param mixed $instance WP_Block instance when available. + */ + $filtered = apply_filters( 'emulsify_theme_core_block_twig_context', $context, $block, $block_content, $template, $instance ); + + return is_array( $filtered ) ? $filtered : $context; + } + + /** + * Gets rendered inner content for Twig convenience. + * + * @param array $block Parsed block metadata. + * @param string $block_content Rendered WordPress block content. + * @return string Inner content. + */ + private function inner_content( array $block, string $block_content ): string { + if ( empty( $block['innerContent'] ) || ! is_array( $block['innerContent'] ) ) { + return $block_content; + } + + $content = ''; + + foreach ( $block['innerContent'] as $piece ) { + if ( is_scalar( $piece ) ) { + $content .= (string) $piece; + } + } + + return '' === $content ? $block_content : $content; + } + + /** + * Gets the mapped Twig template for a block. + * + * @param string $block_name Block name. + * @param array $block Parsed block metadata. + * @return string Twig template path, or an empty string when unavailable. + */ + private function template_for_block( string $block_name, array $block ): string { + $map = $this->template_map(); + + if ( ! isset( $map[ $block_name ] ) ) { + return ''; + } + + $template = $this->resolve_template( $map[ $block_name ] ); + + /** + * Filters the resolved Twig template for a block render. + * + * Return an empty string to fall back to WordPress' original block HTML. + * + * @param string $template Resolved Twig template path. + * @param string $block_name Block name. + * @param array $block Parsed block metadata. + * @param array $map Normalized template map. + */ + $filtered = apply_filters( 'emulsify_theme_core_block_twig_template', $template, $block_name, $block, $map ); + + return is_scalar( $filtered ) ? (string) $filtered : $template; + } + + /** + * Gets the configured block-to-template map. + * + * @return array Block template map. + */ + private function template_map(): array { + /** + * Filters block-to-Twig-template mappings. + * + * Keys should be block names such as "core/paragraph". Values should be + * child/parent theme relative Twig paths such as + * "dist/components/paragraph/paragraph.twig". + * + * @param array $map Block template map. + */ + $filtered = apply_filters( 'emulsify_theme_core_block_twig_template_map', array() ); + + if ( ! is_array( $filtered ) ) { + return array(); + } + + $map = array(); + + foreach ( $filtered as $block_name => $template ) { + if ( ! is_scalar( $template ) ) { + continue; + } + + $block_name = strtolower( trim( (string) $block_name ) ); + $template = trim( (string) $template ); + + if ( '' !== $block_name && '' !== $template && preg_match( '/^[a-z0-9-]+\/[a-z0-9-]+$/', $block_name ) ) { + $map[ $block_name ] = $template; + } + } + + return $map; + } + + /** + * Resolves a mapped template path when a readable child/parent file exists. + * + * @param string $template Mapped template path. + * @return string Template path for Timber, or an empty string. + */ + private function resolve_template( string $template ): string { + $template = str_replace( '\\', '/', trim( $template ) ); + $template = ltrim( $template, '/' ); + + if ( '' === $template || false !== strpos( $template, '../' ) || '..' === $template ) { + return ''; + } + + foreach ( $this->theme_roots() as $root ) { + if ( is_readable( rtrim( $root, '/\\' ) . '/' . $template ) ) { + // Return the theme-relative path because Timber resolves templates + // through its configured child/parent loaders. + return $template; + } + } + + return ''; + } + + /** + * Gets child and parent theme roots. + * + * @return array Theme root directories. + */ + private function theme_roots(): array { + $roots = array(); + + if ( function_exists( 'get_stylesheet_directory' ) ) { + $roots[] = get_stylesheet_directory(); + } + + if ( function_exists( 'get_template_directory' ) ) { + $roots[] = get_template_directory(); + } + + return array_values( array_unique( array_filter( $roots ) ) ); + } + + /** + * Checks whether class cleanup is enabled for rendered Twig output. + * + * @param array $block Parsed block metadata. + * @param string $template Twig template being rendered. + * @return bool TRUE when cleanup is enabled. + */ + private function cleanup_classes_enabled( array $block, string $template ): bool { + /** + * Filters whether mapped block Twig output should remove base wp-block classes. + * + * Defaults to false. Cleanup uses WP_HTML_Tag_Processor when available + * and otherwise leaves rendered markup unchanged. + * + * @param bool $enabled Whether class cleanup is enabled. + * @param array $block Parsed block metadata. + * @param string $template Twig template being rendered. + */ + return (bool) apply_filters( 'emulsify_theme_core_block_twig_cleanup_classes_enabled', false, $block, $template ); + } + + /** + * Removes configured classes from Twig output using WP_HTML_Tag_Processor. + * + * @param string $html Rendered Twig output. + * @param array $block Parsed block metadata. + * @return string Filtered HTML. + */ + private function cleanup_classes( string $html, array $block ): string { + if ( ! class_exists( '\WP_HTML_Tag_Processor' ) ) { + return $html; + } + + $classes = $this->cleanup_class_names( $block ); + + if ( empty( $classes ) ) { + return $html; + } + + $processor = new \WP_HTML_Tag_Processor( $html ); + + while ( $processor->next_tag() ) { + // Prefer WordPress' HTML API over regular expressions so class cleanup + // does not corrupt nested markup or attributes. + foreach ( $classes as $class_name ) { + $processor->remove_class( $class_name ); + } + } + + return $processor->get_updated_html(); + } + + /** + * Gets class names to remove from Twig output when cleanup is enabled. + * + * @param array $block Parsed block metadata. + * @return array Class names. + */ + private function cleanup_class_names( array $block ): array { + $block_name = $this->block_name( $block ); + $slug = false === strpos( $block_name, '/' ) ? $block_name : substr( $block_name, strpos( $block_name, '/' ) + 1 ); + $classes = array( 'wp-block' ); + $slug_class = $this->sanitize_html_class( $slug ); + $name_class = $this->sanitize_html_class( str_replace( '/', '-', $block_name ) ); + + if ( '' !== $slug_class ) { + $classes[] = 'wp-block-' . $slug_class; + } + + if ( '' !== $name_class ) { + $classes[] = 'wp-block-' . $name_class; + } + + /** + * Filters class names removed from mapped block Twig output. + * + * This filter is only used when class cleanup is enabled separately. + * + * @param array $classes Class names to remove. + * @param array $block Parsed block metadata. + */ + $filtered = apply_filters( 'emulsify_theme_core_block_twig_cleanup_class_names', array_values( array_unique( $classes ) ), $block ); + + if ( ! is_array( $filtered ) ) { + return array_values( array_unique( $classes ) ); + } + + $normalized = array(); + + foreach ( $filtered as $class_name ) { + if ( is_scalar( $class_name ) ) { + $class_name = trim( (string) $class_name ); + + if ( '' !== $class_name ) { + $normalized[] = $class_name; + } + } + } + + return array_values( array_unique( $normalized ) ); + } + + /** + * Gets a parsed block name. + * + * @param array $block Parsed block metadata. + * @return string Block name. + */ + private function block_name( array $block ): string { + return isset( $block['blockName'] ) && is_scalar( $block['blockName'] ) + ? strtolower( trim( (string) $block['blockName'] ) ) + : ''; + } + + /** + * Returns original content and appends diagnostics for editors/admins only. + * + * @param string $block_content Original block content. + * @param string $message Diagnostic message. + * @param array $block Parsed block metadata. + * @param string $template Twig template being rendered. + * @return string Original content plus optional diagnostic markup. + */ + private function render_error( string $block_content, string $message, array $block, string $template ): string { + if ( defined( 'WP_DEBUG' ) && WP_DEBUG && function_exists( 'error_log' ) ) { + error_log( + sprintf( + '[Emulsify] Core block Twig render failed for %s with %s: %s', + $this->block_name( $block ), + $template, + $message + ) + ); + } + + if ( ! $this->can_show_diagnostics() ) { + return $block_content; + } + + return $block_content . sprintf( + '<p class="emulsify-block-render-error"><strong>%s</strong> %s</p>', + $this->esc_html__( 'Emulsify block render error:', 'emulsify' ), + $this->esc_html( $message ) + ); + } + + /** + * Checks whether diagnostics can be shown to the current user. + * + * @return bool TRUE for editors/admins. + */ + private function can_show_diagnostics(): bool { + return function_exists( 'current_user_can' ) + && ( current_user_can( 'edit_posts' ) || current_user_can( 'edit_theme_options' ) ); + } + + /** + * Sanitizes an HTML class. + * + * @param string $class_name Raw class name. + * @return string Safe class name. + */ + private function sanitize_html_class( string $class_name ): string { + if ( function_exists( 'sanitize_html_class' ) ) { + return sanitize_html_class( $class_name ); + } + + return trim( preg_replace( '/[^A-Za-z0-9_-]+/', '-', $class_name ), '-' ); + } + + /** + * Escapes translated text. + * + * @param string $text Text. + * @param string $domain Text domain. + * @return string Escaped translated text. + */ + private function esc_html__( string $text, string $domain ): string { + // phpcs:ignore WordPress.WP.I18n.NonSingularStringLiteralText, WordPress.WP.I18n.NonSingularStringLiteralDomain -- Internal translation proxy receives literal strings from this class. + return function_exists( 'esc_html__' ) ? esc_html__( $text, $domain ) : $this->esc_html( $text ); + } + + /** + * Translates text when WordPress is available. + * + * @param string $text Text. + * @param string $domain Text domain. + * @return string Translated text. + */ + private function translate( string $text, string $domain ): string { + // phpcs:ignore WordPress.WP.I18n.NonSingularStringLiteralText, WordPress.WP.I18n.NonSingularStringLiteralDomain -- Internal translation proxy receives literal strings from this class. + return function_exists( '__' ) ? __( $text, $domain ) : $text; + } + + /** + * Escapes HTML text. + * + * @param string $text Text. + * @return string Escaped text. + */ + private function esc_html( string $text ): string { + return function_exists( 'esc_html' ) ? esc_html( $text ) : htmlspecialchars( $text, ENT_QUOTES, 'UTF-8' ); + } +} diff --git a/includes/Blocks/NativeBlocks.php b/includes/Blocks/NativeBlocks.php new file mode 100644 index 0000000..43b2d2e --- /dev/null +++ b/includes/Blocks/NativeBlocks.php @@ -0,0 +1,135 @@ +<?php +/** + * Registers native Gutenberg blocks. + * + * @package Emulsify + */ + +namespace Emulsify\Theme\Blocks; + +use Emulsify\Theme\Support\Diagnostics; + +/** + * Native WordPress Block API integration. + */ +final class NativeBlocks { + + /** + * Component locator. + * + * @var ComponentLocator + */ + private $components; + + /** + * Constructor. + * + * @param ComponentLocator|null $components Component locator. + */ + public function __construct( ?ComponentLocator $components = null ) { + $this->components = $components ?? new ComponentLocator(); + } + + /** + * Registers native block hooks. + * + * @return void + */ + public function register(): void { + add_action( 'init', array( $this, 'register_blocks' ) ); + } + + /** + * Registers component folders that contain block.json. + * + * @return void + */ + public function register_blocks(): void { + if ( ! function_exists( 'register_block_type' ) ) { + // Older or incomplete WordPress contexts may not expose the Block API. + // Smoke tests can still load the class without registering anything. + return; + } + + $directories = $this->components->native_block_directories(); + + /** + * Filters native block directories before they are registered. + * + * Directory records should include path, relative, metadata_path, source, + * and name keys when known. Duplicate native block names are checked after + * this filter runs. + * + * @param array $directories Native block directory records. + * @param ComponentLocator $components Component locator instance. + */ + $filtered = apply_filters( 'emulsify_theme_native_block_directories', $directories, $this->components ); + + if ( is_array( $filtered ) ) { + $directories = $filtered; + } + + $seen_names = array(); + $skipped = array(); + + foreach ( $directories as $component ) { + if ( ! is_array( $component ) || empty( $component['path'] ) ) { + continue; + } + + $name = ! empty( $component['name'] ) && is_scalar( $component['name'] ) ? trim( (string) $component['name'] ) : ''; + + if ( '' !== $name && isset( $seen_names[ $name ] ) ) { + // Filters may merge or rename block directories. Keep the first + // discovered name and surface later duplicates only in debug/admin contexts. + $skipped[] = Diagnostics::duplicate_record( + 'native_filtered_block_name', + $name, + $seen_names[ $name ], + $component, + 'Duplicate filtered native block name.' + ); + continue; + } + + if ( '' !== $name && $this->native_block_registered( $name ) ) { + $skipped[] = Diagnostics::duplicate_record( + 'native_registered_block_name', + $name, + array( + 'name' => $name, + 'source' => 'existing', + ), + $component, + 'Native block name is already registered.' + ); + continue; + } + + if ( '' !== $name ) { + $seen_names[ $name ] = $component; + } + + register_block_type( (string) $component['path'] ); + } + + Diagnostics::report_duplicates( + array_merge( + $this->components->skipped_duplicates( 'native' ), + $skipped + ), + function_exists( '__' ) ? __( 'Emulsify skipped duplicate native block definitions.', 'emulsify' ) : 'Emulsify skipped duplicate native block definitions.' + ); + } + + /** + * Checks whether WordPress already knows about a native block name. + * + * @param string $name Native block name. + * @return bool TRUE when the block is already registered. + */ + private function native_block_registered( string $name ): bool { + return class_exists( '\WP_Block_Type_Registry' ) + && \WP_Block_Type_Registry::get_instance()->is_registered( $name ); + } +} diff --git a/includes/Blocks/Patterns.php b/includes/Blocks/Patterns.php new file mode 100644 index 0000000..3181a3e --- /dev/null +++ b/includes/Blocks/Patterns.php @@ -0,0 +1,622 @@ +<?php +/** + * Registers block patterns from JSON metadata. + * + * @package Emulsify + */ + +namespace Emulsify\Theme\Blocks; + +use Emulsify\Theme\Support\FileDiscovery; + +/** + * Discovers child-first JSON block patterns. + */ +final class Patterns { + + /** + * Supported pattern category metadata filenames. + * + * @var array + */ + private const CATEGORY_METADATA_FILES = array( 'categories.json', '_categories.json' ); + + /** + * Registers pattern hooks. + * + * @return void + */ + public function register(): void { + add_action( 'init', array( $this, 'register_patterns' ), 9 ); + } + + /** + * Registers JSON block patterns and their categories. + * + * @return void + */ + public function register_patterns(): void { + if ( ! function_exists( 'register_block_pattern' ) ) { + return; + } + + $patterns = $this->pattern_records(); + + if ( empty( $patterns ) ) { + return; + } + + $this->register_categories( $this->category_records( $patterns ), $patterns ); + + foreach ( $patterns as $pattern ) { + register_block_pattern( $pattern['name'], $pattern['args'] ); + } + } + + /** + * Gets valid pattern records from discovered JSON files. + * + * @return array Pattern records. + */ + private function pattern_records(): array { + $patterns = array(); + $seen_names = array(); + + foreach ( $this->pattern_files() as $file ) { + $data = $this->pattern_data( $file ); + + if ( null === $data ) { + continue; + } + + $args = $this->pattern_args( $data, $file ); + + if ( null === $args ) { + continue; + } + + $name = $args['name']; + unset( $args['name'] ); + + if ( isset( $seen_names[ $name ] ) || $this->pattern_registered( $name ) ) { + $this->debug( + sprintf( + 'Skipped duplicate block pattern "%s" from %s.', + $name, + $file['path'] + ) + ); + continue; + } + + $seen_names[ $name ] = true; + $patterns[] = array( + 'name' => $name, + 'args' => $args, + 'data' => $data, + 'file' => $file, + ); + } + + return $patterns; + } + + /** + * Discovers child-first pattern JSON files. + * + * @return array Pattern file records. + */ + private function pattern_files(): array { + $files = array(); + $seen_relative = array(); + $seen_file_path = array(); + + foreach ( FileDiscovery::file_records( $this->pattern_directories(), array( 'json' ), false ) as $file ) { + $path = $file['path']; + $relative = basename( $path ); + $realpath = realpath( $path ); + $real = false !== $realpath ? $realpath : $path; + + if ( $this->is_category_metadata_file( $relative ) ) { + continue; + } + + if ( isset( $seen_file_path[ $real ] ) ) { + continue; + } + + if ( isset( $seen_relative[ $relative ] ) ) { + // Directories are scanned child-first. A child JSON file with the + // same basename intentionally overrides the parent starter file. + $this->debug( + sprintf( + 'Skipped duplicate block pattern JSON file "%s" from %s.', + $relative, + $path + ) + ); + continue; + } + + $seen_file_path[ $real ] = true; + $seen_relative[ $relative ] = true; + $files[] = array( + 'path' => $path, + 'relative' => $relative, + 'source' => $file['root_source'], + ); + } + + return $files; + } + + /** + * Gets pattern directories. + * + * @return array Pattern directory records. + */ + private function pattern_directories(): array { + $directories = FileDiscovery::theme_roots( 'patterns' ); + + /** + * Filters directories scanned for JSON block patterns. + * + * Directory records may be strings or arrays with path and source keys. + * Direct child and parent pattern directories are provided child-first. + * + * @param array $directories Pattern directory records. + */ + $filtered = apply_filters( 'emulsify_theme_pattern_directories', $directories ); + + if ( is_array( $filtered ) ) { + $directories = $filtered; + } + + foreach ( $directories as $index => $directory ) { + if ( is_string( $directory ) ) { + $directories[ $index ] = array( + 'path' => $directory, + 'source' => 'filtered', + ); + } + } + + return FileDiscovery::normalize_roots( + $directories, + array( + 'default_source' => null, + ) + ); + } + + /** + * Reads and filters decoded pattern metadata. + * + * @param array $file Pattern file record. + * @return array|null Pattern data, or null when invalid. + */ + private function pattern_data( array $file ): ?array { + $contents = file_get_contents( $file['path'] ); + + if ( ! is_string( $contents ) ) { + $this->debug( sprintf( 'Could not read block pattern JSON file: %s.', $file['path'] ) ); + return null; + } + + $data = json_decode( $contents, true ); + + if ( ! is_array( $data ) ) { + $this->debug( sprintf( 'Could not decode block pattern JSON file: %s.', $file['path'] ) ); + return null; + } + + /** + * Filters decoded JSON block pattern data before validation. + * + * @param array $data Decoded pattern data. + * @param array $file Pattern file record. + */ + $filtered = apply_filters( 'emulsify_theme_pattern_data', $data, $file ); + + return is_array( $filtered ) ? $filtered : $data; + } + + /** + * Builds final block pattern registration arguments. + * + * @param array $data Pattern metadata. + * @param array $file Pattern file record. + * @return array|null Pattern registration args with a temporary name key. + */ + private function pattern_args( array $data, array $file ): ?array { + $name = $this->pattern_name( $data['name'] ?? null ); + $title = $this->string_value( $data['title'] ?? null ); + $content = $this->string_value( $data['content'] ?? null ); + + if ( '' === $name || '' === $title || '' === $content ) { + // Invalid JSON should never break a site. Keep diagnostics behind + // WP_DEBUG so production visitors do not see authoring mistakes. + $this->debug( sprintf( 'Skipped invalid block pattern JSON file: %s.', $file['path'] ) ); + return null; + } + + $args = array( + 'name' => $name, + 'title' => $title, + 'content' => $content, + ); + + foreach ( array( 'description', 'categories', 'keywords', 'postTypes', 'viewportWidth' ) as $key ) { + if ( array_key_exists( $key, $data ) ) { + $args[ $key ] = $this->metadata_value( $key, $data[ $key ] ); + } + } + + $args = array_filter( + $args, + static function ( $value ): bool { + return null !== $value && array() !== $value && '' !== $value; + } + ); + + /** + * Filters final block pattern registration arguments. + * + * The name key is passed to register_block_pattern() separately after + * this filter returns. + * + * @param array $args Pattern registration arguments including name. + * @param array $data Filtered pattern metadata. + * @param array $file Pattern file record. + */ + $filtered = apply_filters( 'emulsify_theme_pattern_args', $args, $data, $file ); + + if ( is_array( $filtered ) ) { + $args = $filtered; + } + + $args['name'] = $this->pattern_name( $args['name'] ?? null ); + $args['title'] = $this->string_value( $args['title'] ?? null ); + $args['content'] = $this->string_value( $args['content'] ?? null ); + + if ( '' === $args['name'] || '' === $args['title'] || '' === $args['content'] ) { + $this->debug( sprintf( 'Skipped invalid filtered block pattern JSON file: %s.', $file['path'] ) ); + return null; + } + + if ( isset( $args['categories'] ) ) { + $args['categories'] = $this->string_list( $args['categories'], true ); + } + + if ( isset( $args['keywords'] ) ) { + $args['keywords'] = $this->string_list( $args['keywords'] ); + } + + if ( isset( $args['postTypes'] ) ) { + $args['postTypes'] = $this->string_list( $args['postTypes'], true ); + } + + if ( isset( $args['viewportWidth'] ) ) { + $args['viewportWidth'] = (int) $args['viewportWidth']; + } + + return $args; + } + + /** + * Gets a normalized metadata value. + * + * @param string $key Metadata key. + * @param mixed $value Metadata value. + * @return mixed Normalized metadata value. + */ + private function metadata_value( string $key, $value ) { + if ( 'description' === $key ) { + return $this->string_value( $value ); + } + + if ( in_array( $key, array( 'categories', 'keywords', 'postTypes' ), true ) ) { + return $this->string_list( $value, 'keywords' !== $key ); + } + + if ( 'viewportWidth' === $key && is_numeric( $value ) ) { + return (int) $value; + } + + return null; + } + + /** + * Builds category records from valid pattern metadata. + * + * @param array $patterns Valid pattern records. + * @return array Category records keyed by category slug. + */ + private function category_records( array $patterns ): array { + $categories = array(); + $metadata = $this->category_metadata(); + + foreach ( $patterns as $pattern ) { + foreach ( $pattern['args']['categories'] ?? array() as $category ) { + if ( ! is_string( $category ) || isset( $categories[ $category ] ) ) { + continue; + } + + $categories[ $category ] = array( + // WordPress requires a label when registering a category. Use a + // readable default and let projects refine it through the filter. + 'label' => $this->label_from_slug( $category ), + 'description' => '', + ); + + if ( isset( $metadata[ $category ] ) ) { + $categories[ $category ] = array_merge( $categories[ $category ], $metadata[ $category ] ); + } + } + } + + /** + * Filters block pattern categories discovered from JSON metadata. + * + * Category records are keyed by slug and include label and description. + * + * @param array $categories Pattern category records. + * @param array $patterns Valid pattern records. + */ + $filtered = apply_filters( 'emulsify_theme_pattern_categories', $categories, $patterns ); + + return is_array( $filtered ) ? $filtered : $categories; + } + + /** + * Gets merged child-over-parent pattern category metadata. + * + * @return array Category metadata keyed by slug. + */ + private function category_metadata(): array { + $metadata = array(); + + foreach ( array_reverse( $this->pattern_directories() ) as $directory ) { + if ( empty( $directory['path'] ) || ! is_scalar( $directory['path'] ) ) { + continue; + } + + foreach ( self::CATEGORY_METADATA_FILES as $filename ) { + $path = rtrim( (string) $directory['path'], '/\\' ) . '/' . $filename; + + if ( ! is_readable( $path ) ) { + continue; + } + + foreach ( $this->category_metadata_file( $path ) as $slug => $category ) { + $metadata[ $slug ] = array_merge( $metadata[ $slug ] ?? array(), $category ); + } + } + } + + return $metadata; + } + + /** + * Reads one category metadata file. + * + * @param string $path Metadata file path. + * @return array Category metadata keyed by slug. + */ + private function category_metadata_file( string $path ): array { + $contents = file_get_contents( $path ); + + if ( ! is_string( $contents ) ) { + $this->debug( sprintf( 'Could not read block pattern category metadata file: %s.', $path ) ); + return array(); + } + + $data = json_decode( $contents, true ); + + if ( ! is_array( $data ) ) { + $this->debug( sprintf( 'Could not decode block pattern category metadata file: %s.', $path ) ); + return array(); + } + + $metadata = array(); + + foreach ( $data as $slug => $category ) { + $slug = $this->category_name( $slug ); + + if ( '' === $slug || ! is_array( $category ) ) { + continue; + } + + $record = array_filter( + array( + 'label' => $this->string_value( $category['label'] ?? ( $category['title'] ?? '' ) ), + 'description' => $this->string_value( $category['description'] ?? '' ), + ), + static function ( string $value ): bool { + return '' !== $value; + } + ); + + if ( ! empty( $record ) ) { + $metadata[ $slug ] = $record; + } + } + + return $metadata; + } + + /** + * Checks whether a pattern JSON file is category metadata. + * + * @param string $relative File basename. + * @return bool TRUE when the file stores category metadata. + */ + private function is_category_metadata_file( string $relative ): bool { + return in_array( strtolower( $relative ), self::CATEGORY_METADATA_FILES, true ); + } + + /** + * Registers block pattern categories when WordPress supports them. + * + * @param array $categories Pattern category records. + * @param array $patterns Valid pattern records. + * @return void + */ + private function register_categories( array $categories, array $patterns ): void { + if ( empty( $patterns ) || ! function_exists( 'register_block_pattern_category' ) ) { + return; + } + + foreach ( $categories as $slug => $category ) { + $name = $this->category_name( $slug ); + + if ( '' === $name || $this->category_registered( $name ) ) { + continue; + } + + $args = is_array( $category ) ? $category : array(); + $args = array_filter( + array( + 'label' => $this->string_value( $args['label'] ?? $this->label_from_slug( $name ) ), + 'description' => $this->string_value( $args['description'] ?? '' ), + ), + static function ( string $value ): bool { + return '' !== $value; + } + ); + + if ( empty( $args['label'] ) ) { + continue; + } + + register_block_pattern_category( $name, $args ); + } + } + + /** + * Checks whether WordPress already knows about a pattern. + * + * @param string $name Pattern name. + * @return bool TRUE when the pattern is already registered. + */ + private function pattern_registered( string $name ): bool { + if ( ! class_exists( '\WP_Block_Patterns_Registry' ) ) { + return false; + } + + $registry = \WP_Block_Patterns_Registry::get_instance(); + + return method_exists( $registry, 'is_registered' ) && $registry->is_registered( $name ); + } + + /** + * Checks whether WordPress already knows about a pattern category. + * + * @param string $name Category name. + * @return bool TRUE when the category is already registered. + */ + private function category_registered( string $name ): bool { + foreach ( array( '\WP_Block_Pattern_Categories_Registry', '\WP_Block_Pattern_Category_Registry' ) as $class ) { + if ( ! class_exists( $class ) ) { + continue; + } + + $registry = $class::get_instance(); + + if ( method_exists( $registry, 'is_registered' ) && $registry->is_registered( $name ) ) { + return true; + } + } + + return false; + } + + /** + * Normalizes a pattern name. + * + * @param mixed $name Pattern name candidate. + * @return string Pattern name, or an empty string. + */ + private function pattern_name( $name ): string { + $name = $this->string_value( $name ); + $name = strtolower( trim( $name ) ); + + return preg_match( '/^[a-z0-9-]+\/[a-z0-9-]+$/', $name ) ? $name : ''; + } + + /** + * Normalizes a pattern category name. + * + * @param mixed $name Category name candidate. + * @return string Category name, or an empty string. + */ + private function category_name( $name ): string { + if ( ! is_scalar( $name ) ) { + return ''; + } + + $name = strtolower( trim( (string) $name ) ); + $name = preg_replace( '/[^a-z0-9_-]+/', '-', $name ); + + return trim( (string) $name, '-' ); + } + + /** + * Gets a scalar string value. + * + * @param mixed $value Value candidate. + * @return string String value, or an empty string. + */ + private function string_value( $value ): string { + return is_scalar( $value ) ? trim( (string) $value ) : ''; + } + + /** + * Normalizes a metadata string list. + * + * @param mixed $value List candidate. + * @param bool $slug Whether values should be slug-normalized. + * @return array String values. + */ + private function string_list( $value, bool $slug = false ): array { + if ( ! is_array( $value ) ) { + return array(); + } + + $items = array(); + + foreach ( $value as $item ) { + $item = $slug ? $this->category_name( $item ) : $this->string_value( $item ); + + if ( '' !== $item ) { + $items[] = $item; + } + } + + return array_values( array_unique( $items ) ); + } + + /** + * Creates a readable label from a slug. + * + * @param string $slug Slug. + * @return string Label. + */ + private function label_from_slug( string $slug ): string { + return ucwords( str_replace( array( '-', '_' ), ' ', $slug ) ); + } + + /** + * Logs diagnostics only when WP_DEBUG is enabled. + * + * @param string $message Diagnostic message. + * @return void + */ + private function debug( string $message ): void { + if ( ! defined( 'WP_DEBUG' ) || ! WP_DEBUG ) { + return; + } + + error_log( '[Emulsify] ' . $message ); + } +} diff --git a/includes/Blocks/Registry.php b/includes/Blocks/Registry.php new file mode 100644 index 0000000..c61cea0 --- /dev/null +++ b/includes/Blocks/Registry.php @@ -0,0 +1,53 @@ +<?php +/** + * Coordinates block integrations. + * + * @package Emulsify + */ + +namespace Emulsify\Theme\Blocks; + +use Emulsify\Theme\Support\AssetManifest; + +/** + * Registers optional block integrations. + */ +final class Registry { + + /** + * Asset manifest reader. + * + * @var AssetManifest + */ + private $manifest; + + /** + * Constructor. + * + * @param AssetManifest|null $manifest Asset manifest reader. + */ + public function __construct( ?AssetManifest $manifest = null ) { + $this->manifest = $manifest ?? new AssetManifest(); + } + + /** + * Registers block integrations. + * + * @return void + */ + public function register(): void { + add_action( + 'switch_theme', + static function (): void { + ComponentLocator::clear_discovery_cache(); + } + ); + + $components = new ComponentLocator(); + + // Share one locator so ACF/Twig and native block registration use the same + // request-local child-first component index and duplicate diagnostics. + ( new AcfBlocks( $components, $this->manifest ) )->register(); + ( new NativeBlocks( $components ) )->register(); + } +} diff --git a/includes/Bootstrap.php b/includes/Bootstrap.php new file mode 100644 index 0000000..4b609fd --- /dev/null +++ b/includes/Bootstrap.php @@ -0,0 +1,152 @@ +<?php +/** + * Loads and starts the Emulsify theme runtime. + * + * @package Emulsify + */ + +namespace Emulsify\Theme; + +use Emulsify\Theme\Acf\LocalJson; +use Emulsify\Theme\Blocks\CoreBlockTwigRenderer; +use Emulsify\Theme\Blocks\Patterns; +use Emulsify\Theme\Cli\GenerateChildThemeCommand; +use Emulsify\Theme\Editor\Enhancements; +use Emulsify\Theme\Editor\Policy; +use Emulsify\Theme\Runtime\Assets; +use Emulsify\Theme\Runtime\Context; +use Emulsify\Theme\Runtime\MissingTimber; +use Emulsify\Theme\Runtime\Setup; +use Emulsify\Theme\Runtime\TimberIntegration; +use Emulsify\Theme\Runtime\Twig; +use Emulsify\Theme\Support\AssetManifest; + +/** + * Coordinates theme services. + */ +final class Bootstrap { + + /** + * Absolute path to the parent theme directory. + * + * @var string + */ + private $theme_dir; + + /** + * Starts the theme runtime. + * + * @param string $theme_dir Absolute path to the parent theme directory. + * @return void + */ + public static function init( string $theme_dir ): void { + $bootstrap = new self( $theme_dir ); + $bootstrap->register(); + } + + /** + * Constructor. + * + * @param string $theme_dir Absolute path to the parent theme directory. + */ + private function __construct( string $theme_dir ) { + $this->theme_dir = rtrim( $theme_dir, '/\\' ); + } + + /** + * Registers all theme services. + * + * @return void + */ + private function register(): void { + $this->load_vendor_autoload(); + $this->load_classes(); + + $asset_manifest = new AssetManifest(); + + // These services use WordPress APIs directly and must stay available even + // when Timber is missing. The MissingTimber service owns frontend failure + // handling later in this method. + ( new Setup() )->register(); + ( new Assets( $asset_manifest ) )->register(); + ( new LocalJson() )->register(); + ( new CoreBlockTwigRenderer() )->register(); + ( new Enhancements( $asset_manifest ) )->register(); + ( new Policy() )->register(); + ( new Patterns() )->register(); + ( new Blocks\Registry( $asset_manifest ) )->register(); + ( new GenerateChildThemeCommand() )->register(); + + $timber = new TimberIntegration(); + + if ( $timber->register() ) { + // Timber-dependent services are registered only after Timber has + // initialized, which keeps admin, CLI, and non-template requests usable + // in partially installed environments. + ( new Context() )->register(); + ( new Twig() )->register(); + return; + } + + ( new MissingTimber() )->register(); + } + + /** + * Loads Composer dependencies when the theme was installed with Composer. + * + * @return void + */ + private function load_vendor_autoload(): void { + $autoload = $this->theme_dir . '/vendor/autoload.php'; + + if ( is_readable( $autoload ) ) { + require_once $autoload; + } + } + + /** + * Loads theme service classes. + * + * @return void + */ + private function load_classes(): void { + spl_autoload_register( + function ( string $class ): void { + $this->autoload_runtime_class( $class ); + } + ); + } + + /** + * Loads runtime classes when Composer autoloading is unavailable. + * + * @param string $class Fully qualified class name. + * @return void + */ + private function autoload_runtime_class( string $class ): void { + $file = $this->runtime_class_file( $class ); + + if ( null !== $file && is_readable( $file ) ) { + require_once $file; + } + } + + /** + * Resolves runtime class files for PSR-4 paths. + * + * @param string $class Fully qualified class name. + * @return string|null Runtime class file path, or null for another namespace. + */ + private function runtime_class_file( string $class ): ?string { + $prefix = __NAMESPACE__ . '\\'; + + if ( 0 !== strpos( $class, $prefix ) ) { + return null; + } + + $relative_class = substr( $class, strlen( $prefix ) ); + $relative_path = str_replace( '\\', '/', $relative_class ); + + return $this->theme_dir . '/includes/' . $relative_path . '.php'; + } +} diff --git a/includes/Cli/GenerateChildThemeCommand.php b/includes/Cli/GenerateChildThemeCommand.php new file mode 100644 index 0000000..7421743 --- /dev/null +++ b/includes/Cli/GenerateChildThemeCommand.php @@ -0,0 +1,899 @@ +<?php +/** + * Registers WP-CLI commands. + * + * @package Emulsify + */ + +namespace Emulsify\Theme\Cli; + +/** + * Provides the `wp emulsify` command. + */ +final class GenerateChildThemeCommand { + + /** + * Bundled starter child theme directory. + */ + private const STARTER_SLUG = 'whisk'; + + /** + * Project metadata source identifier for generated child themes. + */ + private const GENERATED_FROM = 'emulsify-wordpress'; + + /** + * Fallback generated child theme source version. + */ + private const GENERATED_FROM_VERSION = '2.0.0'; + + /** + * Dependency/cache/build paths that should never be copied into a generated + * child theme. + */ + private const EXCLUDED_COPY_PATHS = array( + '.git', + '.coverage', + '.out', + 'dist', + 'node_modules', + ); + + /** + * Registers the command when WP-CLI is available. + * + * @return void + */ + public function register(): void { + if ( ! class_exists( '\WP_CLI' ) ) { + return; + } + + \WP_CLI::add_command( 'emulsify', $this ); + } + + /** + * Generates an Emulsify child theme from the bundled Whisk starter. + * + * ## OPTIONS + * + * <name> + * : Human-readable child theme name. + * + * [--machine-name=<slug>] + * : Lowercase slug for the generated theme directory, text domain, package + * name, and Emulsify machineName. Defaults to a slug generated from <name>. + * + * [--dry-run] + * : Show what would be created or changed without writing files. + * + * [--force] + * : Replace an existing destination directory only when it looks like an + * Emulsify-generated child theme. + * + * [--activate] + * : Activate the generated child theme after creation. + * + * @param array $args Positional arguments. + * @param array $assoc_args Named arguments. + * @return void + */ + public function __invoke( array $args, array $assoc_args ): void { + $this->generate( $args, $assoc_args ); + } + + /** + * Generates a new child theme from whisk. + * + * @param array $args Positional arguments. + * @param array $assoc_args Named arguments. + * @return void + */ + private function generate( array $args, array $assoc_args ): void { + $label = $this->get_theme_label( $args ); + $machine_name = $this->get_machine_name( $label, $assoc_args ); + $parent = $this->get_parent_slug( $assoc_args ); + $dry_run = $this->get_flag_value( $assoc_args, 'dry-run' ); + $force = $this->get_flag_value( $assoc_args, 'force' ); + $activate = $this->get_flag_value( $assoc_args, 'activate' ); + $source = $this->join_path( get_theme_root(), $parent, self::STARTER_SLUG ); + $destination = $this->join_path( get_theme_root(), $machine_name ); + $version = $this->get_generated_from_version( $source ); + + \WP_CLI::log( sprintf( 'Generating child theme "%s" (%s) from Emulsify.', $label, $machine_name ) ); + \WP_CLI::log( sprintf( 'Source: %s', $source ) ); + \WP_CLI::log( sprintf( 'Destination: %s', $destination ) ); + + if ( ! is_dir( $source ) ) { + \WP_CLI::error( sprintf( 'Source directory not found: %s', $source ) ); + } + + if ( $machine_name === $parent ) { + \WP_CLI::error( sprintf( 'The machine name "%s" would overwrite the parent theme. Choose a different --machine-name.', $machine_name ) ); + } + + if ( file_exists( $destination ) && ! $force ) { + \WP_CLI::error( sprintf( 'Destination already exists: %s. Use --force to replace it.', $destination ) ); + } + + $metadata_updates = $this->collect_metadata_updates( + $source, + array( + 'label' => $label, + 'machine_name' => $machine_name, + 'parent' => $parent, + 'version' => $version, + ) + ); + + if ( $dry_run ) { + $this->report_dry_run( $source, $destination, $metadata_updates, $force, $activate, $machine_name ); + return; + } + + if ( file_exists( $destination ) ) { + $replacement_error = $this->get_destination_replacement_error( $destination, $parent ); + + if ( null !== $replacement_error ) { + \WP_CLI::error( + sprintf( + 'Refusing to replace existing destination because it does not look like an Emulsify-generated child theme: %s (%s). Remove it manually or choose a different --machine-name.', + $destination, + $replacement_error + ) + ); + } + + \WP_CLI::warning( sprintf( 'Replacing existing destination because --force was provided: %s', $destination ) ); + + if ( ! $this->remove_path( $destination ) ) { + \WP_CLI::error( sprintf( 'Failed removing existing destination: %s', $destination ) ); + } + } + + if ( ! $this->copy_theme( $source, $destination ) ) { + \WP_CLI::error( sprintf( 'Failed generating child theme at: %s', $destination ) ); + } + + $metadata_updates = $this->collect_metadata_updates( + $destination, + array( + 'label' => $label, + 'machine_name' => $machine_name, + 'parent' => $parent, + 'version' => $version, + ) + ); + $this->apply_metadata_updates( $destination, $metadata_updates ); + + if ( $activate ) { + $this->activate_theme( $machine_name ); + } else { + \WP_CLI::log( sprintf( 'Activate it with: wp theme activate %s', $machine_name ) ); + } + + \WP_CLI::success( sprintf( 'Child theme "%s" created at "%s".', $label, $destination ) ); + } + + /** + * Copies the starter theme into the child theme destination. + * + * @param string $source Source directory. + * @param string $destination Destination directory. + * @return bool TRUE on success. + */ + private function copy_theme( string $source, string $destination ): bool { + if ( ! $this->make_directory( $destination ) ) { + return false; + } + + try { + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator( $source, \RecursiveDirectoryIterator::SKIP_DOTS ), + \RecursiveIteratorIterator::SELF_FIRST + ); + } catch ( \UnexpectedValueException $exception ) { + \WP_CLI::warning( sprintf( 'Could not read source directory: %s', $exception->getMessage() ) ); + return false; + } + + foreach ( $iterator as $item ) { + $path = $item->getPathname(); + $relative = $this->relative_path( $source, $path ); + + if ( $this->should_skip_copy_path( $relative ) ) { + continue; + } + + $target = $this->join_path( $destination, $relative ); + + if ( $item->isDir() ) { + if ( ! $this->make_directory( $target ) ) { + \WP_CLI::warning( sprintf( 'Could not create directory: %s', $target ) ); + return false; + } + + continue; + } + + if ( $item->isLink() ) { + \WP_CLI::warning( sprintf( 'Skipping symlink in starter theme: %s', $path ) ); + continue; + } + + if ( ! $this->make_directory( dirname( $target ) ) || ! copy( $path, $target ) ) { + \WP_CLI::warning( sprintf( 'Could not copy %s to %s.', $path, $target ) ); + return false; + } + } + + return true; + } + + /** + * Collects targeted starter metadata updates. + * + * @param string $root Theme root to read. + * @param array $config Generation config. + * @return array<int, array{file:string,contents:string}> + */ + private function collect_metadata_updates( string $root, array $config ): array { + $updates = array(); + $theme_label = $config['label']; + $machine_name = $config['machine_name']; + $parent = $config['parent']; + $version = $config['version']; + + // Update known metadata surfaces deliberately. Avoid blind recursive text + // replacement so example prose, generated assets, and project content are + // not mutated unexpectedly. + $this->collect_text_update( + $updates, + $root, + 'style.css', + function ( string $contents ) use ( $theme_label, $machine_name, $parent ): string { + $contents = $this->replace_theme_header( $contents, 'Theme Name', $theme_label ); + $contents = $this->replace_theme_header( $contents, 'Text Domain', $machine_name ); + return $this->replace_theme_header( $contents, 'Template', $parent ); + } + ); + + $this->collect_json_update( + $updates, + $root, + 'package.json', + function ( array $data ) use ( $machine_name ): array { + $data['name'] = $machine_name; + return $data; + } + ); + + $this->collect_json_update( + $updates, + $root, + 'project.emulsify.json', + function ( array $data ) use ( $theme_label, $machine_name, $version ): array { + if ( ! isset( $data['project'] ) || ! is_array( $data['project'] ) ) { + $data['project'] = array(); + } + + // phpcs:ignore WordPress.WP.CapitalPDangit.MisspelledInText -- project.platform is a machine-readable adapter key. + $data['project']['platform'] = 'wordpress'; + $data['project']['name'] = $theme_label; + $data['project']['machineName'] = $machine_name; + $data['project']['generatedFrom'] = self::GENERATED_FROM; + $data['project']['generatedFromVersion'] = $version; + + return $data; + } + ); + + $this->collect_text_update( + $updates, + $root, + 'functions.php', + function ( string $contents ) use ( $theme_label ): string { + return str_replace( 'Whisk child theme hooks.', $theme_label . ' child theme hooks.', $contents ); + } + ); + + $this->collect_text_update( + $updates, + $root, + 'templates/page.twig', + function ( string $contents ) use ( $machine_name ): string { + return str_replace( self::STARTER_SLUG . '-page', $machine_name . '-page', $contents ); + } + ); + + $this->collect_pattern_updates( $updates, $root, $machine_name ); + + return $updates; + } + + /** + * Adds starter pattern metadata updates. + * + * @param array $updates Update accumulator. + * @param string $root Theme root. + * @param string $machine_name Generated child theme machine name. + * @return void + */ + private function collect_pattern_updates( array &$updates, string $root, string $machine_name ): void { + $pattern_dir = $this->join_path( $root, 'patterns' ); + + if ( ! is_dir( $pattern_dir ) ) { + // Patterns are optional in Whisk. Empty generated themes should not pay + // a filesystem or warning cost for a feature they have not adopted. + return; + } + + $files = glob( $pattern_dir . '/*.json' ); + + if ( ! is_array( $files ) ) { + return; + } + + sort( $files ); + + foreach ( $files as $path ) { + $relative = 'patterns/' . basename( $path ); + + $this->collect_json_update( + $updates, + $root, + $relative, + function ( array $data ) use ( $machine_name ): array { + if ( isset( $data['name'] ) && is_string( $data['name'] ) && 0 === strpos( $data['name'], self::STARTER_SLUG . '/' ) ) { + $data['name'] = $machine_name . '/' . substr( $data['name'], strlen( self::STARTER_SLUG ) + 1 ); + } + + return $data; + } + ); + } + } + + /** + * Adds a text file update when the callback changes the contents. + * + * @param array $updates Update accumulator. + * @param string $root Theme root. + * @param string $relative Relative file path. + * @param callable $callback Content updater. + * @return void + */ + private function collect_text_update( array &$updates, string $root, string $relative, callable $callback ): void { + $path = $this->join_path( $root, $relative ); + + if ( ! is_readable( $path ) ) { + \WP_CLI::warning( sprintf( 'Expected starter file is not readable: %s', $path ) ); + return; + } + + $contents = file_get_contents( $path ); + + if ( ! is_string( $contents ) ) { + \WP_CLI::warning( sprintf( 'Could not read starter file: %s', $path ) ); + return; + } + + $updated = $callback( $contents ); + + if ( $updated !== $contents ) { + $updates[] = array( + 'file' => $relative, + 'contents' => $updated, + ); + } + } + + /** + * Adds a JSON file update when the callback changes the decoded data. + * + * @param array $updates Update accumulator. + * @param string $root Theme root. + * @param string $relative Relative file path. + * @param callable $callback Data updater. + * @return void + */ + private function collect_json_update( array &$updates, string $root, string $relative, callable $callback ): void { + $path = $this->join_path( $root, $relative ); + + if ( ! is_readable( $path ) ) { + \WP_CLI::warning( sprintf( 'Expected starter JSON file is not readable: %s', $path ) ); + return; + } + + $contents = file_get_contents( $path ); + + if ( ! is_string( $contents ) ) { + \WP_CLI::warning( sprintf( 'Could not read starter JSON file: %s', $path ) ); + return; + } + + $data = json_decode( $contents, true ); + + if ( ! is_array( $data ) ) { + \WP_CLI::warning( sprintf( 'Could not decode starter JSON file: %s', $path ) ); + return; + } + + $updated_data = $callback( $data ); + $updated = json_encode( $updated_data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ); + + if ( ! is_string( $updated ) ) { + \WP_CLI::warning( sprintf( 'Could not encode starter JSON file: %s', $path ) ); + return; + } + + $updated .= "\n"; + + if ( $updated !== $contents ) { + $updates[] = array( + 'file' => $relative, + 'contents' => $updated, + ); + } + } + + /** + * Applies collected metadata updates to a generated child theme. + * + * @param string $destination Destination theme root. + * @param array $updates File updates. + * @return void + */ + private function apply_metadata_updates( string $destination, array $updates ): void { + foreach ( $updates as $update ) { + $path = $this->join_path( $destination, $update['file'] ); + + if ( false === file_put_contents( $path, $update['contents'] ) ) { + \WP_CLI::warning( sprintf( 'Could not update generated file: %s', $path ) ); + continue; + } + + \WP_CLI::log( sprintf( 'Updated %s.', $update['file'] ) ); + } + } + + /** + * Reports a dry-run plan. + * + * @param string $source Source theme root. + * @param string $destination Destination theme root. + * @param array $metadata_updates Planned metadata updates. + * @param bool $force Whether force replacement was requested. + * @param bool $activate Whether activation was requested. + * @param string $machine_name Generated machine name. + * @return void + */ + private function report_dry_run( string $source, string $destination, array $metadata_updates, bool $force, bool $activate, string $machine_name ): void { + \WP_CLI::log( 'Dry run: no files will be written.' ); + + if ( file_exists( $destination ) && $force ) { + \WP_CLI::warning( sprintf( 'Would replace existing destination because --force was provided: %s', $destination ) ); + } + + \WP_CLI::log( sprintf( 'Would copy %d starter files from %s.', $this->count_copyable_files( $source ), $source ) ); + + foreach ( $metadata_updates as $update ) { + \WP_CLI::log( sprintf( 'Would update %s.', $update['file'] ) ); + } + + if ( $activate ) { + \WP_CLI::log( sprintf( 'Would activate child theme "%s".', $machine_name ) ); + } + + \WP_CLI::success( sprintf( 'Dry run complete. Child theme would be created at "%s".', $destination ) ); + } + + /** + * Activates a generated child theme. + * + * @param string $machine_name Theme stylesheet slug. + * @return void + */ + private function activate_theme( string $machine_name ): void { + if ( ! function_exists( 'switch_theme' ) ) { + \WP_CLI::warning( sprintf( 'Could not activate "%s" because switch_theme() is unavailable.', $machine_name ) ); + return; + } + + switch_theme( $machine_name ); + + \WP_CLI::success( sprintf( 'Activated child theme "%s".', $machine_name ) ); + } + + /** + * Replaces a WordPress theme header value. + * + * @param string $contents File contents. + * @param string $field Header field. + * @param string $value Header value. + * @return string Updated file contents. + */ + private function replace_theme_header( string $contents, string $field, string $value ): string { + $pattern = '/^(\s*\*\s*' . preg_quote( $field, '/' ) . ':\s*).*$/mi'; + $updated = preg_replace_callback( + $pattern, + static function ( array $matches ) use ( $value ): string { + return $matches[1] . $value; + }, + $contents, + 1 + ); + + if ( ! is_string( $updated ) || $updated === $contents ) { + \WP_CLI::warning( sprintf( 'Could not update "%s" in style.css.', $field ) ); + return $contents; + } + + return $updated; + } + + /** + * Counts files that would be copied from the starter. + * + * @param string $source Source theme root. + * @return int Copyable file count. + */ + private function count_copyable_files( string $source ): int { + $count = 0; + + try { + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator( $source, \RecursiveDirectoryIterator::SKIP_DOTS ), + \RecursiveIteratorIterator::SELF_FIRST + ); + } catch ( \UnexpectedValueException $exception ) { + return 0; + } + + foreach ( $iterator as $item ) { + if ( ! $item->isFile() ) { + continue; + } + + $relative = $this->relative_path( $source, $item->getPathname() ); + + if ( ! $this->should_skip_copy_path( $relative ) ) { + ++$count; + } + } + + return $count; + } + + /** + * Gets the version to record in generated child theme metadata. + * + * @param string $source Starter source path. + * @return string Version string. + */ + private function get_generated_from_version( string $source ): string { + $package = $this->read_json_file( $this->join_path( dirname( $source ), 'package.json' ) ); + + if ( isset( $package['version'] ) && is_string( $package['version'] ) && '' !== trim( $package['version'] ) ) { + return trim( $package['version'] ); + } + + return self::GENERATED_FROM_VERSION; + } + + /** + * Gets the reason an existing destination should not be force-replaced. + * + * @param string $destination Destination theme root. + * @param string $parent Selected parent theme slug. + * @return string|null Error reason, or NULL when replacement is allowed. + */ + private function get_destination_replacement_error( string $destination, string $parent ): ?string { + if ( ! is_dir( $destination ) ) { + return 'destination is not a theme directory'; + } + + $template = $this->read_theme_header_value( $this->join_path( $destination, 'style.css' ), 'Template' ); + + if ( null === $template ) { + return 'missing readable style.css Template header'; + } + + $allowed_templates = array_values( array_unique( array_filter( array( 'emulsify', $parent ) ) ) ); + + if ( ! in_array( $template, $allowed_templates, true ) ) { + return sprintf( 'style.css Template is "%s", expected "%s"', $template, implode( '" or "', $allowed_templates ) ); + } + + $project = $this->read_json_file( $this->join_path( $destination, 'project.emulsify.json' ) ); + + if ( null === $project ) { + return 'missing readable project.emulsify.json'; + } + + if ( ! isset( $project['project'] ) || ! is_array( $project['project'] ) ) { + return 'project.emulsify.json is missing project metadata'; + } + + // phpcs:ignore WordPress.WP.CapitalPDangit.MisspelledInText -- project.platform is a machine-readable adapter key. + if ( 'wordpress' !== ( $project['project']['platform'] ?? null ) ) { + // phpcs:ignore WordPress.WP.CapitalPDangit.MisspelledInText -- Diagnostic quotes the machine-readable project.platform value. + return 'project.emulsify.json is missing project.platform: wordpress'; + } + + if ( ! isset( $project['project']['machineName'] ) || ! is_string( $project['project']['machineName'] ) || '' === trim( $project['project']['machineName'] ) ) { + return 'project.emulsify.json is missing project.machineName'; + } + + $has_generated_from = array_key_exists( 'generatedFrom', $project['project'] ); + $has_generated_from_version = array_key_exists( 'generatedFromVersion', $project['project'] ); + + if ( $has_generated_from && ( ! is_string( $project['project']['generatedFrom'] ) || '' === trim( $project['project']['generatedFrom'] ) ) ) { + return 'project.emulsify.json has an invalid project.generatedFrom'; + } + + if ( $has_generated_from && self::GENERATED_FROM !== $project['project']['generatedFrom'] ) { + return sprintf( 'project.emulsify.json generatedFrom is "%s", expected "%s"', $project['project']['generatedFrom'], self::GENERATED_FROM ); + } + + if ( $has_generated_from && ( ! $has_generated_from_version || ! is_string( $project['project']['generatedFromVersion'] ) || '' === trim( $project['project']['generatedFromVersion'] ) ) ) { + return 'project.emulsify.json is missing project.generatedFromVersion'; + } + + if ( ! $has_generated_from && $has_generated_from_version ) { + return 'project.emulsify.json has project.generatedFromVersion without project.generatedFrom'; + } + + return null; + } + + /** + * Reads a WordPress theme header value. + * + * @param string $path File path. + * @param string $field Header field. + * @return string|null Header value, or NULL when unavailable. + */ + private function read_theme_header_value( string $path, string $field ): ?string { + if ( ! is_readable( $path ) ) { + return null; + } + + $contents = file_get_contents( $path ); + + if ( ! is_string( $contents ) ) { + return null; + } + + if ( ! preg_match( '/^\s*(?:\*\s*)?' . preg_quote( $field, '/' ) . ':\s*(.+?)\s*$/mi', $contents, $matches ) ) { + return null; + } + + return trim( $matches[1] ); + } + + /** + * Reads a JSON file. + * + * @param string $path File path. + * @return array|null Decoded JSON data, or NULL when unavailable. + */ + private function read_json_file( string $path ): ?array { + if ( ! is_readable( $path ) ) { + return null; + } + + $contents = file_get_contents( $path ); + + if ( ! is_string( $contents ) ) { + return null; + } + + $data = json_decode( $contents, true ); + + return is_array( $data ) ? $data : null; + } + + /** + * Removes a generated destination before force replacement. + * + * @param string $path Path to remove. + * @return bool TRUE on success. + */ + private function remove_path( string $path ): bool { + if ( ! file_exists( $path ) ) { + return true; + } + + // --force removal is scoped to the computed child theme destination. The + // generator validates that destination before this method is called. + if ( is_file( $path ) || is_link( $path ) ) { + return unlink( $path ); + } + + try { + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator( $path, \RecursiveDirectoryIterator::SKIP_DOTS ), + \RecursiveIteratorIterator::CHILD_FIRST + ); + } catch ( \UnexpectedValueException $exception ) { + \WP_CLI::warning( sprintf( 'Could not read destination for removal: %s', $exception->getMessage() ) ); + return false; + } + + foreach ( $iterator as $item ) { + $item_path = $item->getPathname(); + + if ( $item->isDir() && ! $item->isLink() ) { + if ( ! rmdir( $item_path ) ) { + return false; + } + } elseif ( ! unlink( $item_path ) ) { + return false; + } + } + + return rmdir( $path ); + } + + /** + * Creates a directory through WordPress when available. + * + * @param string $path Directory path. + * @return bool TRUE on success. + */ + private function make_directory( string $path ): bool { + if ( is_dir( $path ) ) { + return true; + } + + if ( function_exists( 'wp_mkdir_p' ) ) { + return wp_mkdir_p( $path ); + } + + return mkdir( $path, 0777, true ); + } + + /** + * Checks whether a starter path should be skipped during copy. + * + * @param string $relative Relative starter path. + * @return bool TRUE when the path should be skipped. + */ + private function should_skip_copy_path( string $relative ): bool { + $parts = preg_split( '#[\\\\/]#', $relative ); + + if ( ! is_array( $parts ) ) { + return false; + } + + foreach ( $parts as $part ) { + if ( in_array( $part, self::EXCLUDED_COPY_PATHS, true ) ) { + // Match any path segment so nested dependencies and generated build + // output cannot leak into a new project child theme. + return true; + } + } + + return false; + } + + /** + * Converts a human theme name into a filesystem-safe slug. + * + * @param string $label Theme label. + * @return string Theme slug. + */ + private static function convert_label_to_machine_name( string $label ): string { + $slug = preg_replace( '/[^a-z0-9]+/i', '-', strtolower( $label ) ); + $slug = preg_replace( '/-{2,}/', '-', (string) $slug ); + $slug = trim( (string) $slug, '-' ); + + return $slug; + } + + /** + * Gets the sanitized human-readable child theme label. + * + * @param array $args Positional CLI arguments. + * @return string Theme label. + */ + private function get_theme_label( array $args ): string { + $label = isset( $args[0] ) && is_string( $args[0] ) && '' !== trim( $args[0] ) ? $args[0] : 'New Theme'; + $label = function_exists( 'sanitize_text_field' ) ? sanitize_text_field( $label ) : trim( strip_tags( $label ) ); + + return '' !== $label ? $label : 'New Theme'; + } + + /** + * Gets the generated or explicitly supplied machine name. + * + * @param string $label Theme label. + * @param array $assoc_args Named CLI arguments. + * @return string Theme slug. + */ + private function get_machine_name( string $label, array $assoc_args ): string { + $raw_machine_name = isset( $assoc_args['machine-name'] ) && is_string( $assoc_args['machine-name'] ) + ? $assoc_args['machine-name'] + : $label; + $machine_name = self::convert_label_to_machine_name( $raw_machine_name ); + + if ( '' === $machine_name && isset( $assoc_args['machine-name'] ) ) { + \WP_CLI::error( 'Machine name cannot be empty.' ); + } + + if ( '' === $machine_name ) { + $machine_name = 'new-theme'; + } + + if ( isset( $assoc_args['machine-name'] ) && $raw_machine_name !== $machine_name ) { + \WP_CLI::warning( sprintf( 'Normalized --machine-name from "%s" to "%s".', $raw_machine_name, $machine_name ) ); + } + + return $machine_name; + } + + /** + * Gets the parent theme slug. + * + * @param array $assoc_args Named CLI arguments. + * @return string Parent slug. + */ + private function get_parent_slug( array $assoc_args ): string { + $parent = isset( $assoc_args['parent'] ) && is_string( $assoc_args['parent'] ) ? $assoc_args['parent'] : 'emulsify'; + + if ( function_exists( 'sanitize_key' ) ) { + $parent = sanitize_key( $parent ); + } else { + $parent = preg_replace( '/[^a-z0-9_-]/', '', strtolower( $parent ) ); + } + + return '' !== $parent ? $parent : 'emulsify'; + } + + /** + * Reads a boolean WP-CLI flag. + * + * @param array $assoc_args Named CLI arguments. + * @param string $name Flag name. + * @return bool TRUE when the flag is enabled. + */ + private function get_flag_value( array $assoc_args, string $name ): bool { + if ( ! array_key_exists( $name, $assoc_args ) ) { + return false; + } + + return false !== $assoc_args[ $name ] && 'false' !== $assoc_args[ $name ] && '0' !== $assoc_args[ $name ]; + } + + /** + * Computes a path relative to a root. + * + * @param string $root Root path. + * @param string $path Full path. + * @return string Relative path. + */ + private function relative_path( string $root, string $path ): string { + return ltrim( substr( $path, strlen( rtrim( $root, '/\\' ) ) ), '/\\' ); + } + + /** + * Joins path segments. + * + * @param string ...$segments Path segments. + * @return string Joined path. + */ + private function join_path( string ...$segments ): string { + $path = ''; + + foreach ( $segments as $segment ) { + if ( '' === $segment ) { + continue; + } + + $path = '' === $path ? rtrim( $segment, '/\\' ) : $path . '/' . trim( $segment, '/\\' ); + } + + return $path; + } +} diff --git a/includes/Editor/AllowedBlockTypes.php b/includes/Editor/AllowedBlockTypes.php new file mode 100644 index 0000000..75cf7d2 --- /dev/null +++ b/includes/Editor/AllowedBlockTypes.php @@ -0,0 +1,160 @@ +<?php +/** + * Applies optional allowed block type policy. + * + * @package Emulsify + */ + +namespace Emulsify\Theme\Editor; + +/** + * Allowed block type governance. + */ +final class AllowedBlockTypes { + + /** + * Shared policy option resolver. + * + * @var PolicyOptions + */ + private $options; + + /** + * Pattern governance helper. + * + * @var PatternGovernance + */ + private $patterns; + + /** + * Constructor. + * + * @param PolicyOptions $options Policy option resolver. + * @param PatternGovernance $patterns Pattern governance helper. + */ + public function __construct( PolicyOptions $options, PatternGovernance $patterns ) { + $this->options = $options; + $this->patterns = $patterns; + } + + /** + * Filters allowed block types for the current editor context. + * + * @param array|bool $allowed_block_types Existing WordPress allowed block types value. + * @param mixed $context Block editor context. + * @return array|bool Filtered allowed block types. + */ + public function filter( $allowed_block_types, $context ) { + $options = $this->options->get( $context ); + $post_type = $this->post_type( $context ); + $blocks = $this->configured_allowed_block_types( $options['allowed_block_types'] ?? null, $post_type ); + + /** + * Filters the resolved allowed block type list before WordPress receives it. + * + * Return null to preserve the incoming WordPress value. Return an array + * of block names to enable an allow list for the current editor context. + * + * @param array|null $blocks Resolved configured block names, or null for no policy. + * @param string $post_type Current post type when available. + * @param mixed $context Block editor context. + * @param array|bool $allowed_block_types Incoming WordPress allowed block types value. + * @param array $options Editor policy options. + */ + $filtered = apply_filters( 'emulsify_theme_allowed_block_types', $blocks, $post_type, $context, $allowed_block_types, $options ); + + if ( null === $filtered || ! is_array( $filtered ) ) { + // Null is the "no opinion" value. Returning the incoming WordPress value + // preserves default editor behavior unless a child theme configures a policy. + return $allowed_block_types; + } + + $blocks = BlockNames::normalize( $filtered ); + + if ( ! empty( $options['auto_allow_pattern_blocks'] ) ) { + // Pattern JSON can reference supporting blocks that are easy to forget + // in a manual allow list. This opt-in merge prevents configured patterns + // from becoming impossible to insert. + $blocks = array_merge( $blocks, $this->patterns->pattern_block_names( $options ) ); + } + + if ( is_array( $allowed_block_types ) && ! empty( $options['merge_allowed_block_types'] ) ) { + $blocks = array_merge( $allowed_block_types, $blocks ); + } + + return array_values( array_unique( BlockNames::normalize( $blocks ) ) ); + } + + /** + * Resolves configured allowed blocks for a post type. + * + * @param mixed $config Allowed block type configuration. + * @param string $post_type Post type when available. + * @return array|null Block names, or null when no policy is configured. + */ + private function configured_allowed_block_types( $config, string $post_type ): ?array { + if ( ! is_array( $config ) ) { + return null; + } + + if ( array_is_list( $config ) ) { + return $config; + } + + $blocks = array(); + $configured = false; + + foreach ( array( 'default', '*' ) as $key ) { + if ( array_key_exists( $key, $config ) && is_array( $config[ $key ] ) ) { + $blocks = array_merge( $blocks, $config[ $key ] ); + $configured = true; + } + } + + foreach ( array( 'by_post_type', 'post_types' ) as $group_key ) { + if ( '' === $post_type || ! isset( $config[ $group_key ] ) || ! is_array( $config[ $group_key ] ) ) { + continue; + } + + $post_type_blocks = array_key_exists( $post_type, $config[ $group_key ] ) ? $config[ $group_key ][ $post_type ] : null; + + if ( is_array( $post_type_blocks ) ) { + $blocks = array_merge( $blocks, $post_type_blocks ); + $configured = true; + } + } + + if ( '' !== $post_type && array_key_exists( $post_type, $config ) && is_array( $config[ $post_type ] ) ) { + $blocks = array_merge( $blocks, $config[ $post_type ] ); + $configured = true; + } + + return $configured ? $blocks : null; + } + + /** + * Gets the post type from a block editor context. + * + * @param mixed $context Block editor context. + * @return string Post type or an empty string. + */ + private function post_type( $context ): string { + if ( is_object( $context ) && isset( $context->post ) ) { + if ( function_exists( 'get_post_type' ) ) { + $post_type = get_post_type( $context->post ); + + return is_string( $post_type ) ? $post_type : ''; + } + + if ( is_object( $context->post ) && isset( $context->post->post_type ) && is_scalar( $context->post->post_type ) ) { + return (string) $context->post->post_type; + } + } + + if ( is_object( $context ) && isset( $context->post_type ) && is_scalar( $context->post_type ) ) { + return (string) $context->post_type; + } + + return ''; + } +} diff --git a/includes/Editor/BlockNames.php b/includes/Editor/BlockNames.php new file mode 100644 index 0000000..38cc5de --- /dev/null +++ b/includes/Editor/BlockNames.php @@ -0,0 +1,58 @@ +<?php +/** + * Normalizes block names used by editor policy services. + * + * @package Emulsify + */ + +namespace Emulsify\Theme\Editor; + +/** + * Block name normalization helper. + */ +final class BlockNames { + + /** + * Normalizes a list of block names. + * + * @param array $blocks Block name candidates. + * @return array Normalized block names. + */ + public static function normalize( array $blocks ): array { + $names = array(); + + foreach ( $blocks as $block ) { + $name = self::normalize_one( $block ); + + if ( '' !== $name ) { + $names[] = $name; + } + } + + return array_values( array_unique( $names ) ); + } + + /** + * Normalizes a block name candidate. + * + * @param mixed $block Block name candidate. + * @return string Block name or an empty string. + */ + private static function normalize_one( $block ): string { + if ( ! is_scalar( $block ) ) { + return ''; + } + + $name = strtolower( trim( (string) $block ) ); + + if ( '' === $name ) { + return ''; + } + + if ( false === strpos( $name, '/' ) ) { + $name = 'core/' . $name; + } + + return preg_match( '/^[a-z0-9-]+\/[a-z0-9-]+$/', $name ) ? $name : ''; + } +} diff --git a/includes/Editor/BlockSupportOverrides.php b/includes/Editor/BlockSupportOverrides.php new file mode 100644 index 0000000..1860c50 --- /dev/null +++ b/includes/Editor/BlockSupportOverrides.php @@ -0,0 +1,123 @@ +<?php +/** + * Applies optional block support/style overrides. + * + * @package Emulsify + */ + +namespace Emulsify\Theme\Editor; + +/** + * Block support and style override policy. + */ +final class BlockSupportOverrides { + + /** + * Shared policy option resolver. + * + * @var PolicyOptions + */ + private $options; + + /** + * Constructor. + * + * @param PolicyOptions $options Policy option resolver. + */ + public function __construct( PolicyOptions $options ) { + $this->options = $options; + } + + /** + * Applies configured support/style overrides to metadata-derived settings. + * + * @param array $settings Block type metadata settings. + * @param array $metadata Block metadata. + * @return array Filtered metadata settings. + */ + public function block_type_metadata_settings( array $settings, array $metadata ): array { + $block_name = isset( $metadata['name'] ) && is_scalar( $metadata['name'] ) ? (string) $metadata['name'] : ''; + + return $this->apply( $settings, $block_name, 'block_type_metadata_settings' ); + } + + /** + * Applies configured support/style overrides to final block registration args. + * + * @param array $args Block type registration arguments. + * @param string $block_type Block type name. + * @return array Filtered block type registration arguments. + */ + public function register_block_type_args( array $args, string $block_type ): array { + return $this->apply( $args, $block_type, 'register_block_type_args' ); + } + + /** + * Applies configured block support/style overrides. + * + * @param array $settings Block type settings or registration arguments. + * @param string $block_name Block type name. + * @param string $source Current WordPress filter name. + * @return array Filtered block settings. + */ + private function apply( array $settings, string $block_name, string $source ): array { + $options = $this->options->get(); + $overrides = isset( $options['block_support_overrides'] ) && is_array( $options['block_support_overrides'] ) + ? $options['block_support_overrides'] + : array(); + + /** + * Filters block support/style overrides for a block registration pass. + * + * Use the "*" key for all blocks and a block name key such as + * "core/button" for block-specific changes. + * + * @param array $overrides Configured override map. + * @param string $block_name Current block name. + * @param array $settings Current settings or registration arguments. + * @param string $source Current WordPress filter name. + * @param array $options Editor policy options. + */ + $filtered = apply_filters( 'emulsify_theme_block_support_overrides', $overrides, $block_name, $settings, $source, $options ); + + if ( is_array( $filtered ) ) { + $overrides = $filtered; + } + + foreach ( array( '*', $block_name ) as $key ) { + if ( isset( $overrides[ $key ] ) && is_array( $overrides[ $key ] ) ) { + // Apply global overrides first and block-specific overrides second so + // a child theme can set broad defaults with targeted exceptions. + $settings = $this->merge_override( $settings, $overrides[ $key ] ); + } + } + + return $settings; + } + + /** + * Recursively applies an override array. + * + * @param array $base Base array. + * @param array $override Override array. + * @return array Merged array. + */ + private function merge_override( array $base, array $override ): array { + foreach ( $override as $key => $value ) { + if ( + array_key_exists( $key, $base ) + && is_array( $base[ $key ] ) + && is_array( $value ) + && ! array_is_list( $base[ $key ] ) + && ! array_is_list( $value ) + ) { + $base[ $key ] = $this->merge_override( $base[ $key ], $value ); + continue; + } + + $base[ $key ] = $value; + } + + return $base; + } +} diff --git a/includes/Editor/Enhancements.php b/includes/Editor/Enhancements.php new file mode 100644 index 0000000..665876e --- /dev/null +++ b/includes/Editor/Enhancements.php @@ -0,0 +1,551 @@ +<?php +/** + * Enqueues optional block editor enhancements. + * + * @package Emulsify + */ + +namespace Emulsify\Theme\Editor; + +use Emulsify\Theme\Support\AssetEnqueuer; +use Emulsify\Theme\Support\AssetManifest; +use Emulsify\Theme\Support\FileDiscovery; + +/** + * Optional editor enhancement runtime. + */ +final class Enhancements { + + /** + * Asset manifest reader. + * + * @var AssetManifest + */ + private $manifest; + + /** + * Memoized editor enhancement configuration. + * + * @var array|null + */ + private $config; + + /** + * Constructor. + * + * @param AssetManifest|null $manifest Asset manifest reader. + */ + public function __construct( ?AssetManifest $manifest = null ) { + $this->manifest = $manifest ?? new AssetManifest(); + } + + /** + * Registers editor enhancement hooks. + * + * @return void + */ + public function register(): void { + add_action( 'enqueue_block_editor_assets', array( $this, 'editor_assets' ) ); + add_filter( 'render_block_data', array( $this, 'render_block_data' ), 20, 3 ); + add_filter( 'render_block', array( $this, 'render_block' ), 20, 3 ); + } + + /** + * Enqueues built child-theme editor assets when available. + * + * @return void + */ + public function editor_assets(): void { + $config = $this->config(); + + // The parent ships the enqueue/runtime support only. A child theme or + // selected component system decides whether any editor bundle exists. + foreach ( $this->asset_files( 'dist/global/editor', array( 'css' ) ) as $asset ) { + AssetEnqueuer::enqueue_style( 'emulsify-editor', $asset ); + } + + foreach ( $this->asset_files( 'dist/global/editor', array( 'js' ) ) as $asset ) { + $handle = AssetEnqueuer::handle( 'emulsify-editor', $asset ); + + wp_enqueue_script( + $handle, + $asset['uri'], + $this->editor_script_dependencies( $asset ), + $asset['version'], + array( + 'in_footer' => true, + ) + ); + + wp_add_inline_script( + $handle, + // Expose the normalized PHP config before the editor bundle runs so + // modules can stay declarative and filter-driven. + 'window.emulsifyEditorEnhancements = ' . $this->json_encode( $config ) . ';', + 'before' + ); + } + } + + /** + * Adds a file-caption class to parsed File blocks when configured. + * + * @param array $parsed_block Parsed block data. + * @param array $source_block Source block data. + * @param mixed $parent_block Parent block instance. + * @return array Filtered parsed block data. + */ + public function render_block_data( array $parsed_block, array $source_block = array(), $parent_block = null ): array { + unset( $source_block, $parent_block ); + + $config = $this->config(); + + if ( ! $this->file_caption_enabled( $config ) || ! $this->is_file_block( $parsed_block ) ) { + return $parsed_block; + } + + $settings = $config['fileCaption']; + $attribute = $this->string_option( $settings['attribute'] ?? '', 'emulsifyShowMediaCaption' ); + + if ( empty( $parsed_block['attrs'][ $attribute ] ) ) { + return $parsed_block; + } + + $class = $this->string_option( $settings['blockClassName'] ?? '', 'has-media-caption' ); + $parsed_block['attrs'] = isset( $parsed_block['attrs'] ) && is_array( $parsed_block['attrs'] ) ? $parsed_block['attrs'] : array(); + $parsed_block['attrs']['className'] = $this->append_class( $parsed_block['attrs']['className'] ?? '', $class ); + + return $parsed_block; + } + + /** + * Appends the media caption to rendered File blocks when configured. + * + * @param string $block_content Rendered block content. + * @param array $block Parsed block data. + * @param mixed $instance Block instance. + * @return string Filtered block content. + */ + public function render_block( string $block_content, array $block, $instance = null ): string { + unset( $instance ); + + $config = $this->config(); + + if ( ! $this->file_caption_enabled( $config ) || ! $this->is_file_block( $block ) ) { + return $block_content; + } + + $settings = $config['fileCaption']; + $attribute = $this->string_option( $settings['attribute'] ?? '', 'emulsifyShowMediaCaption' ); + + if ( empty( $block['attrs'][ $attribute ] ) || false !== strpos( $block_content, 'wp-block-file__media-caption' ) ) { + return $block_content; + } + + $attachment_id = $this->attachment_id( $block['attrs'] ?? array() ); + + if ( $attachment_id <= 0 || ! function_exists( 'wp_get_attachment_caption' ) ) { + return $block_content; + } + + $caption = wp_get_attachment_caption( $attachment_id ); + + if ( ! is_scalar( $caption ) || '' === trim( (string) $caption ) ) { + return $block_content; + } + + $caption_class = $this->string_option( $settings['captionClassName'] ?? '', 'wp-block-file__media-caption' ); + $caption_html = sprintf( + '<p class="%s">%s</p>', + $this->esc_attr( $caption_class ), + $this->esc_html( (string) $caption ) + ); + + if ( preg_match( '/<\/div>\s*$/i', $block_content ) ) { + // core/file normally renders a wrapper div. Append inside that wrapper + // so the caption inherits block spacing and editor/frontend styling. + $filtered = preg_replace( '/<\/div>\s*$/i', $caption_html . '</div>', $block_content, 1 ); + + return is_string( $filtered ) ? $filtered : $block_content; + } + + return $block_content . $caption_html; + } + + /** + * Gets editor enhancement configuration. + * + * @return array Editor enhancement config. + */ + private function config(): array { + if ( is_array( $this->config ) ) { + return $this->config; + } + + $config = array( + 'columnsEqualHeight' => array( + 'enabled' => false, + 'attribute' => 'emulsifyEqualizeHeights', + 'className' => 'is-equal-height', + ), + 'fileCaption' => array( + 'enabled' => false, + 'attribute' => 'emulsifyShowMediaCaption', + 'blockClassName' => 'has-media-caption', + 'captionClassName' => 'wp-block-file__media-caption', + ), + 'embedVariations' => array( + 'enabled' => false, + 'allowedVariationNames' => array(), + ), + 'placement' => array( + 'enabled' => false, + 'blocks' => array(), + 'singleton' => true, + 'requireTop' => true, + ), + ); + + /** + * Filters optional editor enhancement configuration. + * + * Defaults keep every starter module disabled. Child themes can enable + * individual modules by returning nested configuration changes. + * + * @param array $config Editor enhancement configuration. + */ + $filtered = apply_filters( 'emulsify_theme_editor_enhancements_config', $config ); + + if ( is_array( $filtered ) ) { + $config = $this->merge_config( $config, $filtered ); + } + + $this->config = $this->normalize_config( $config ); + + return $this->config; + } + + /** + * Recursively merges editor configuration. + * + * @param array $base Base config. + * @param array $override Override config. + * @return array Merged config. + */ + private function merge_config( array $base, array $override ): array { + foreach ( $override as $key => $value ) { + if ( + array_key_exists( $key, $base ) + && is_array( $base[ $key ] ) + && is_array( $value ) + && ! array_is_list( $base[ $key ] ) + && ! array_is_list( $value ) + ) { + $base[ $key ] = $this->merge_config( $base[ $key ], $value ); + continue; + } + + $base[ $key ] = $value; + } + + return $base; + } + + /** + * Normalizes editor enhancement configuration. + * + * @param array $config Raw config. + * @return array Normalized config. + */ + private function normalize_config( array $config ): array { + foreach ( array( 'columnsEqualHeight', 'fileCaption', 'embedVariations', 'placement' ) as $module ) { + $config[ $module ] = isset( $config[ $module ] ) && is_array( $config[ $module ] ) ? $config[ $module ] : array(); + $config[ $module ]['enabled'] = ! empty( $config[ $module ]['enabled'] ); + } + + $config['embedVariations']['allowedVariationNames'] = $this->string_list( $config['embedVariations']['allowedVariationNames'] ?? array() ); + $config['placement']['blocks'] = $this->string_list( $config['placement']['blocks'] ?? array() ); + $config['placement']['singleton'] = ! empty( $config['placement']['singleton'] ); + $config['placement']['requireTop'] = ! empty( $config['placement']['requireTop'] ); + + return $config; + } + + /** + * Finds editor assets below a theme-relative directory. + * + * @param string $directory Theme-relative asset directory. + * @param array $extensions Allowed file extensions. + * @return array Built asset records. + */ + private function asset_files( string $directory, array $extensions ): array { + $manifest_assets = $this->manifest_asset_files( $directory, $extensions ); + $assets = null === $manifest_assets ? $this->scanned_asset_files( $directory, $extensions ) : $manifest_assets; + + /** + * Filters built editor asset files before they are enqueued. + * + * Asset records should include path, relative, uri, and version keys. + * + * @param array $assets Built editor asset records. + * @param string $directory Theme-relative asset directory being scanned. + * @param array $extensions Allowed file extensions for the current enqueue pass. + */ + $filtered = apply_filters( 'emulsify_theme_editor_asset_files', $assets, $directory, $extensions ); + + return is_array( $filtered ) ? $filtered : $assets; + } + + /** + * Gets manifest-backed editor assets when available. + * + * @param string $directory Theme-relative asset directory. + * @param array $extensions Allowed file extensions. + * @return array|null Manifest-backed records, or null for scanner fallback. + */ + private function manifest_asset_files( string $directory, array $extensions ): ?array { + if ( 'dist/global/editor' !== trim( $directory, '/\\' ) ) { + return null; + } + + return $this->manifest->asset_records( 'editor', $extensions ); + } + + /** + * Finds editor assets below a theme-relative directory through filesystem scans. + * + * @param string $directory Theme-relative asset directory. + * @param array $extensions Allowed file extensions. + * @return array Built asset records. + */ + private function scanned_asset_files( string $directory, array $extensions ): array { + $assets = array(); + $seen = array(); + + foreach ( FileDiscovery::file_records( $this->asset_roots( $directory ), $extensions ) as $file ) { + $relative = $file['relative']; + + if ( isset( $seen[ $relative ] ) ) { + continue; + } + + $seen[ $relative ] = true; + $assets[] = array( + 'path' => $file['path'], + 'priority' => $file['priority'], + 'relative' => $relative, + 'uri' => $file['root_uri'] . '/' . $relative, + 'version' => $this->version( $file['path'] ), + ); + } + + return FileDiscovery::sort_by_priority_and_relative( $assets ); + } + + /** + * Gets child theme editor asset roots first, then parent fallbacks. + * + * @param string $directory Theme-relative asset directory. + * @return array Asset root records. + */ + private function asset_roots( string $directory ): array { + $directories = FileDiscovery::theme_roots( $directory, true ); + + /** + * Filters built editor asset directories before discovery. + * + * Root records should include absolute path, public uri, and priority + * keys. Lower priority values are enqueued first after duplicate relative + * paths are resolved child-first. + * + * @param array $directories Built editor asset directory records. + * @param string $directory Theme-relative asset directory being scanned. + */ + $filtered = apply_filters( 'emulsify_theme_editor_asset_directories', $directories, $directory ); + + if ( is_array( $filtered ) ) { + $directories = $filtered; + } + + return FileDiscovery::normalize_roots( + $directories, + array( + 'require_uri' => true, + ) + ); + } + + /** + * Gets WordPress script dependencies for editor modules. + * + * @param array $asset Asset record. + * @return array Script handles. + */ + private function editor_script_dependencies( array $asset = array() ): array { + $dependencies = array( + 'wp-block-editor', + 'wp-blocks', + 'wp-components', + 'wp-compose', + 'wp-data', + 'wp-dom-ready', + 'wp-element', + 'wp-hooks', + 'wp-i18n', + 'wp-notices', + ); + + return array_values( + array_unique( + array_merge( + $dependencies, + AssetEnqueuer::dependencies( $asset ) + ) + ) + ); + } + + /** + * Checks whether the File block caption module is enabled. + * + * @param array $config Editor enhancement config. + * @return bool TRUE when enabled. + */ + private function file_caption_enabled( array $config ): bool { + return ! empty( $config['fileCaption']['enabled'] ); + } + + /** + * Checks for a core/file parsed block. + * + * @param array $block Parsed block data. + * @return bool TRUE when the block is core/file. + */ + private function is_file_block( array $block ): bool { + return isset( $block['blockName'] ) && 'core/file' === $block['blockName']; + } + + /** + * Gets the attachment ID from File block attributes. + * + * @param array $attributes Block attributes. + * @return int Attachment ID. + */ + private function attachment_id( array $attributes ): int { + foreach ( array( 'id', 'fileId' ) as $key ) { + if ( isset( $attributes[ $key ] ) && is_numeric( $attributes[ $key ] ) ) { + return (int) $attributes[ $key ]; + } + } + + return 0; + } + + /** + * Appends a CSS class to an existing class string. + * + * @param mixed $current Existing class value. + * @param string $class Class to append. + * @return string Class string. + */ + private function append_class( $current, string $class ): string { + $classes = is_scalar( $current ) ? preg_split( '/\s+/', (string) $current ) : array(); + $classes = is_array( $classes ) ? $classes : array(); + $classes[] = $class; + + return trim( implode( ' ', array_unique( array_filter( $classes ) ) ) ); + } + + /** + * Normalizes a string option with a fallback. + * + * @param mixed $value Option value. + * @param string $fallback Fallback value. + * @return string Normalized string. + */ + private function string_option( $value, string $fallback ): string { + if ( ! is_scalar( $value ) ) { + return $fallback; + } + + $value = trim( (string) $value ); + + return '' === $value ? $fallback : $value; + } + + /** + * Normalizes a list of strings. + * + * @param mixed $values Value list. + * @return array Normalized strings. + */ + private function string_list( $values ): array { + if ( ! is_array( $values ) ) { + return array(); + } + + $strings = array(); + + foreach ( $values as $value ) { + if ( ! is_scalar( $value ) ) { + continue; + } + + $value = trim( (string) $value ); + + if ( '' !== $value ) { + $strings[] = $value; + } + } + + return array_values( array_unique( $strings ) ); + } + + /** + * Gets a filemtime-based asset version. + * + * @param string $path Absolute file path. + * @return string|null Asset version. + */ + private function version( string $path ): ?string { + $modified = filemtime( $path ); + + return false === $modified ? null : (string) $modified; + } + + /** + * JSON encodes a value for inline script output. + * + * @param mixed $value Value to encode. + * @return string JSON string. + */ + private function json_encode( $value ): string { + if ( function_exists( 'wp_json_encode' ) ) { + $encoded = wp_json_encode( $value ); + } else { + $encoded = json_encode( $value ); + } + + return is_string( $encoded ) ? $encoded : '{}'; + } + + /** + * Escapes an HTML attribute. + * + * @param string $value Raw attribute. + * @return string Escaped attribute. + */ + private function esc_attr( string $value ): string { + return function_exists( 'esc_attr' ) ? esc_attr( $value ) : htmlspecialchars( $value, ENT_QUOTES, 'UTF-8' ); + } + + /** + * Escapes HTML text. + * + * @param string $value Raw text. + * @return string Escaped text. + */ + private function esc_html( string $value ): string { + return function_exists( 'esc_html' ) ? esc_html( $value ) : htmlspecialchars( $value, ENT_QUOTES, 'UTF-8' ); + } +} diff --git a/includes/Editor/PatternGovernance.php b/includes/Editor/PatternGovernance.php new file mode 100644 index 0000000..235f061 --- /dev/null +++ b/includes/Editor/PatternGovernance.php @@ -0,0 +1,241 @@ +<?php +/** + * Applies optional block pattern governance. + * + * @package Emulsify + */ + +namespace Emulsify\Theme\Editor; + +/** + * Pattern visibility and pattern-derived block helpers. + */ +final class PatternGovernance { + + /** + * Filters block editor settings for optional pattern visibility governance. + * + * @param array $settings Block editor settings. + * @param mixed $context Block editor context. + * @param array $options Editor policy options. + * @return array Filtered settings. + */ + public function filter_editor_settings( array $settings, $context, array $options ): array { + $namespaces = $this->pattern_namespaces( $options, $settings, $context ); + + if ( ! empty( $namespaces ) ) { + $settings['blockPatterns'] = $this->filter_block_patterns( $settings['blockPatterns'] ?? null, $namespaces ); + } + + return $settings; + } + + /** + * Gets block names referenced by configured pattern JSON files. + * + * @param array $options Editor policy options. + * @return array Block names. + */ + public function pattern_block_names( array $options ): array { + $names = array(); + + foreach ( $this->pattern_json_files( $options ) as $file ) { + $contents = file_get_contents( $file ); + + if ( ! is_string( $contents ) || '' === trim( $contents ) ) { + continue; + } + + $data = json_decode( $contents, true ); + + if ( ! is_array( $data ) || empty( $data['content'] ) || ! is_string( $data['content'] ) ) { + continue; + } + + $names = array_merge( $names, $this->block_names_from_content( $data['content'] ) ); + } + + return array_values( array_unique( $names ) ); + } + + /** + * Gets configured block pattern namespaces. + * + * @param array $options Editor policy options. + * @param array $settings Block editor settings. + * @param mixed $context Block editor context. + * @return array Pattern namespaces. + */ + private function pattern_namespaces( array $options, array $settings, $context ): array { + $namespaces = isset( $options['pattern_namespaces'] ) && is_array( $options['pattern_namespaces'] ) + ? $options['pattern_namespaces'] + : array(); + + /** + * Filters the block pattern namespaces that should stay visible. + * + * Return an empty array to preserve the default pattern list. + * + * @param array $namespaces Pattern namespaces, without trailing slashes. + * @param array $settings Block editor settings. + * @param mixed $context Block editor context. + * @param array $options Editor policy options. + */ + $filtered = apply_filters( 'emulsify_theme_pattern_namespaces', $namespaces, $settings, $context, $options ); + + if ( is_array( $filtered ) ) { + $namespaces = $filtered; + } + + return array_values( + array_unique( + array_filter( + array_map( + static function ( $namespace ): string { + return is_scalar( $namespace ) ? trim( strtolower( (string) $namespace ), " \t\n\r\0\x0B/" ) : ''; + }, + $namespaces + ) + ) + ) + ); + } + + /** + * Filters visible block pattern records by namespace. + * + * @param mixed $patterns Block pattern records. + * @param array $namespaces Allowed namespaces. + * @return array Filtered block pattern records. + */ + private function filter_block_patterns( $patterns, array $namespaces ): array { + if ( ! is_array( $patterns ) && class_exists( '\WP_Block_Patterns_Registry' ) ) { + $patterns = \WP_Block_Patterns_Registry::get_instance()->get_all_registered(); + } + + if ( ! is_array( $patterns ) ) { + return array(); + } + + $keep = array(); + + foreach ( $patterns as $key => $pattern ) { + $name = is_string( $key ) ? $key : ''; + + if ( is_array( $pattern ) && isset( $pattern['name'] ) && is_scalar( $pattern['name'] ) ) { + $name = (string) $pattern['name']; + } + + if ( $this->matches_namespace( $name, $namespaces ) ) { + $keep[] = $pattern; + } + } + + return $keep; + } + + /** + * Checks whether a pattern name is in an allowed namespace. + * + * @param string $name Pattern name. + * @param array $namespaces Allowed namespaces. + * @return bool TRUE when the namespace is allowed. + */ + private function matches_namespace( string $name, array $namespaces ): bool { + $name = strtolower( trim( $name ) ); + + foreach ( $namespaces as $namespace ) { + if ( 0 === strpos( $name, $namespace . '/' ) ) { + return true; + } + } + + return false; + } + + /** + * Gets pattern JSON files from configured pattern directories. + * + * @param array $options Editor policy options. + * @return array Absolute file paths. + */ + private function pattern_json_files( array $options ): array { + $directories = isset( $options['pattern_directories'] ) && is_array( $options['pattern_directories'] ) + ? $options['pattern_directories'] + : $this->default_pattern_directories(); + $files = array(); + $seen = array(); + + foreach ( $directories as $directory ) { + if ( ! is_scalar( $directory ) ) { + continue; + } + + $path = rtrim( (string) $directory, '/\\' ); + + if ( '' === $path || ! is_dir( $path ) || ! is_readable( $path ) ) { + continue; + } + + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator( $path, \RecursiveDirectoryIterator::SKIP_DOTS ) + ); + + foreach ( $iterator as $file ) { + if ( ! $file->isFile() || 'json' !== strtolower( $file->getExtension() ) ) { + continue; + } + + $file_path = $file->getPathname(); + $realpath = realpath( $file_path ); + $key = false !== $realpath ? $realpath : $file_path; + + if ( isset( $seen[ $key ] ) ) { + continue; + } + + $seen[ $key ] = true; + $files[] = $file_path; + } + } + + sort( $files ); + + return $files; + } + + /** + * Gets child and parent pattern directories. + * + * @return array Pattern directories. + */ + private function default_pattern_directories(): array { + $directories = array(); + + if ( function_exists( 'get_stylesheet_directory' ) ) { + $directories[] = rtrim( get_stylesheet_directory(), '/\\' ) . '/patterns'; + } + + if ( function_exists( 'get_template_directory' ) ) { + $directories[] = rtrim( get_template_directory(), '/\\' ) . '/patterns'; + } + + return array_values( array_unique( $directories ) ); + } + + /** + * Extracts block names from block comment markup. + * + * @param string $content Pattern block content. + * @return array Block names. + */ + private function block_names_from_content( string $content ): array { + if ( ! preg_match_all( '/<!--\s*wp:([a-z0-9-]+(?:\/[a-z0-9-]+)?)\b/i', $content, $matches ) ) { + return array(); + } + + // Pattern content is serialized Gutenberg markup. Pull names from block + // comments instead of parsing rendered HTML, which would miss dynamic blocks. + return BlockNames::normalize( $matches[1] ); + } +} diff --git a/includes/Editor/Policy.php b/includes/Editor/Policy.php new file mode 100644 index 0000000..1bcfd6b --- /dev/null +++ b/includes/Editor/Policy.php @@ -0,0 +1,143 @@ +<?php +/** + * Coordinates optional block editor governance. + * + * @package Emulsify + */ + +namespace Emulsify\Theme\Editor; + +/** + * Parent-theme block editor policy hooks. + */ +final class Policy { + + /** + * Shared policy option resolver. + * + * @var PolicyOptions + */ + private $options; + + /** + * Allowed block type policy. + * + * @var AllowedBlockTypes + */ + private $allowed_blocks; + + /** + * Pattern visibility policy. + * + * @var PatternGovernance + */ + private $patterns; + + /** + * User-created pattern permissions policy. + * + * @var UserPatternPermissions + */ + private $user_patterns; + + /** + * Block support/style override policy. + * + * @var BlockSupportOverrides + */ + private $support_overrides; + + /** + * Constructor. + * + * @param PolicyOptions|null $options Policy option resolver. + * @param AllowedBlockTypes|null $allowed_blocks Allowed block policy. + * @param PatternGovernance|null $patterns Pattern visibility policy. + * @param UserPatternPermissions|null $user_patterns User pattern policy. + * @param BlockSupportOverrides|null $support_overrides Block support override policy. + */ + public function __construct( + ?PolicyOptions $options = null, + ?AllowedBlockTypes $allowed_blocks = null, + ?PatternGovernance $patterns = null, + ?UserPatternPermissions $user_patterns = null, + ?BlockSupportOverrides $support_overrides = null + ) { + $this->options = $options ?? new PolicyOptions(); + $this->patterns = $patterns ?? new PatternGovernance(); + $this->allowed_blocks = $allowed_blocks ?? new AllowedBlockTypes( $this->options, $this->patterns ); + $this->user_patterns = $user_patterns ?? new UserPatternPermissions( $this->options ); + $this->support_overrides = $support_overrides ?? new BlockSupportOverrides( $this->options ); + } + + /** + * Registers block editor policy hooks. + * + * @return void + */ + public function register(): void { + add_filter( 'allowed_block_types_all', array( $this, 'allowed_block_types' ), 20, 2 ); + add_filter( 'block_editor_settings_all', array( $this, 'block_editor_settings' ), 20, 2 ); + add_filter( 'register_post_type_args', array( $this, 'post_type_args' ), 10, 2 ); + add_filter( 'block_type_metadata_settings', array( $this, 'block_type_metadata_settings' ), 20, 2 ); + add_filter( 'register_block_type_args', array( $this, 'register_block_type_args' ), 20, 2 ); + } + + /** + * Filters allowed block types for the current editor context. + * + * @param array|bool $allowed_block_types Existing WordPress allowed block types value. + * @param mixed $context Block editor context. + * @return array|bool Filtered allowed block types. + */ + public function allowed_block_types( $allowed_block_types, $context ) { + return $this->allowed_blocks->filter( $allowed_block_types, $context ); + } + + /** + * Filters block editor settings for optional pattern governance. + * + * @param array $settings Block editor settings. + * @param mixed $context Block editor context. + * @return array Filtered settings. + */ + public function block_editor_settings( array $settings, $context = null ): array { + $options = $this->options->get( $context ); + $settings = $this->patterns->filter_editor_settings( $settings, $context, $options ); + + return $this->user_patterns->filter_editor_settings( $settings, $context, $options ); + } + + /** + * Optionally changes the creation capability for user-created patterns. + * + * @param array $args Post type registration arguments. + * @param string $post_type Post type slug. + * @return array Filtered post type arguments. + */ + public function post_type_args( array $args, string $post_type ): array { + return $this->user_patterns->post_type_args( $args, $post_type ); + } + + /** + * Applies configured support/style overrides to metadata-derived settings. + * + * @param array $settings Block type metadata settings. + * @param array $metadata Block metadata. + * @return array Filtered metadata settings. + */ + public function block_type_metadata_settings( array $settings, array $metadata ): array { + return $this->support_overrides->block_type_metadata_settings( $settings, $metadata ); + } + + /** + * Applies configured support/style overrides to final block registration args. + * + * @param array $args Block type registration arguments. + * @param string $block_type Block type name. + * @return array Filtered block type registration arguments. + */ + public function register_block_type_args( array $args, string $block_type ): array { + return $this->support_overrides->register_block_type_args( $args, $block_type ); + } +} diff --git a/includes/Editor/PolicyOptions.php b/includes/Editor/PolicyOptions.php new file mode 100644 index 0000000..5977840 --- /dev/null +++ b/includes/Editor/PolicyOptions.php @@ -0,0 +1,71 @@ +<?php +/** + * Normalizes block editor policy options. + * + * @package Emulsify + */ + +namespace Emulsify\Theme\Editor; + +/** + * Shared editor policy options. + */ +final class PolicyOptions { + + /** + * Gets editor policy options. + * + * @param mixed $context Optional editor context. + * @return array Editor policy options. + */ + public function get( $context = null ): array { + $options = array( + 'allowed_block_types' => null, + 'merge_allowed_block_types' => true, + 'auto_allow_pattern_blocks' => false, + 'pattern_directories' => null, + 'pattern_namespaces' => array(), + 'disable_user_patterns_for_non_admins' => false, + 'admin_capability' => 'manage_options', + 'restrict_wp_block_creation' => false, + 'wp_block_create_capability' => 'manage_options', + 'block_support_overrides' => array(), + ); + + /** + * Filters block editor policy options. + * + * Defaults preserve the parent theme's current behavior. Child themes can + * opt into individual policies by returning changed option values. + * + * @param array $options Editor policy options. + * @param mixed $context Optional editor context. + */ + $filtered = apply_filters( 'emulsify_theme_editor_policy_options', $options, $context ); + + if ( is_array( $filtered ) ) { + $options = array_merge( $options, $filtered ); + } + + $options['admin_capability'] = $this->capability( $options['admin_capability'] ?? 'manage_options' ); + $options['wp_block_create_capability'] = $this->capability( $options['wp_block_create_capability'] ?? 'manage_options' ); + + return $options; + } + + /** + * Normalizes a capability string. + * + * @param mixed $capability Capability candidate. + * @return string Capability or an empty string. + */ + private function capability( $capability ): string { + if ( ! is_scalar( $capability ) ) { + return ''; + } + + $normalized = preg_replace( '/[^a-zA-Z0-9_]+/', '', (string) $capability ); + + return is_string( $normalized ) ? trim( $normalized ) : ''; + } +} diff --git a/includes/Editor/UserPatternPermissions.php b/includes/Editor/UserPatternPermissions.php new file mode 100644 index 0000000..f264e43 --- /dev/null +++ b/includes/Editor/UserPatternPermissions.php @@ -0,0 +1,93 @@ +<?php +/** + * Applies optional user-created pattern restrictions. + * + * @package Emulsify + */ + +namespace Emulsify\Theme\Editor; + +/** + * User-created pattern UI and capability governance. + */ +final class UserPatternPermissions { + + /** + * Shared policy option resolver. + * + * @var PolicyOptions + */ + private $options; + + /** + * Constructor. + * + * @param PolicyOptions $options Policy option resolver. + */ + public function __construct( PolicyOptions $options ) { + $this->options = $options; + } + + /** + * Filters block editor settings for optional user pattern UI governance. + * + * @param array $settings Block editor settings. + * @param mixed $context Block editor context. + * @param array $options Editor policy options. + * @return array Filtered settings. + */ + public function filter_editor_settings( array $settings, $context, array $options ): array { + unset( $context ); + + $disable_user_patterns = ! empty( $options['disable_user_patterns_for_non_admins'] ); + + if ( $disable_user_patterns && ! $this->current_user_can( (string) $options['admin_capability'] ) ) { + // WordPress still registers project/theme patterns; this only hides the + // user-created pattern UI for users below the configured capability. + $settings['enableUserPatterns'] = false; + } + + return $settings; + } + + /** + * Optionally changes the creation capability for user-created patterns. + * + * @param array $args Post type registration arguments. + * @param string $post_type Post type slug. + * @return array Filtered post type arguments. + */ + public function post_type_args( array $args, string $post_type ): array { + if ( 'wp_block' !== $post_type ) { + return $args; + } + + $options = $this->options->get(); + + if ( empty( $options['restrict_wp_block_creation'] ) ) { + return $args; + } + + $capability = isset( $options['wp_block_create_capability'] ) ? (string) $options['wp_block_create_capability'] : ''; + + if ( '' === $capability ) { + return $args; + } + + $args['map_meta_cap'] = true; + $args['capabilities'] = isset( $args['capabilities'] ) && is_array( $args['capabilities'] ) ? $args['capabilities'] : array(); + $args['capabilities']['create_posts'] = $capability; + + return $args; + } + + /** + * Safely checks the current user's capabilities. + * + * @param string $capability Capability name. + * @return bool TRUE when the current user has the capability. + */ + private function current_user_can( string $capability ): bool { + return '' !== $capability && function_exists( 'current_user_can' ) && current_user_can( $capability ); + } +} diff --git a/includes/Runtime/Assets.php b/includes/Runtime/Assets.php new file mode 100644 index 0000000..f5280ce --- /dev/null +++ b/includes/Runtime/Assets.php @@ -0,0 +1,470 @@ +<?php +/** + * Enqueues theme assets. + * + * @package Emulsify + */ + +namespace Emulsify\Theme\Runtime; + +use Emulsify\Theme\Support\AssetEnqueuer; +use Emulsify\Theme\Support\AssetManifest; +use Emulsify\Theme\Support\AssetRecord; +use Emulsify\Theme\Support\FileDiscovery; + +/** + * Global asset registration. + */ +final class Assets { + + /** + * Asset manifest reader. + * + * @var AssetManifest + */ + private $manifest; + + /** + * Memoized raw asset file records by theme-relative directory. + * + * @var array + */ + private $asset_file_records = array(); + + /** + * Memoized component metadata asset paths. + * + * @var array + */ + private $component_asset_paths = array(); + + /** + * Memoized manifest-scoped component asset paths. + * + * @var array|null + */ + private $manifest_component_asset_paths; + + /** + * Constructor. + * + * @param AssetManifest|null $manifest Asset manifest reader. + */ + public function __construct( ?AssetManifest $manifest = null ) { + $this->manifest = $manifest ?? new AssetManifest(); + } + + /** + * Registers asset hooks. + * + * @return void + */ + public function register(): void { + add_action( 'enqueue_block_assets', array( $this, 'styles' ) ); + add_action( 'wp_enqueue_scripts', array( $this, 'frontend_scripts' ) ); + } + + /** + * Enqueues theme styles for the frontend and block editor preview. + * + * @return void + */ + public function styles(): void { + $this->enqueue_styles( 'emulsify-global', 'dist/global' ); + $this->enqueue_styles( 'emulsify-component', 'dist/components' ); + } + + /** + * Enqueues frontend component scripts. + * + * @return void + */ + public function frontend_scripts(): void { + $this->enqueue_scripts( 'emulsify-global', 'dist/global' ); + $this->enqueue_scripts( 'emulsify-component', 'dist/components' ); + } + + /** + * Enqueues CSS files from a built asset directory. + * + * @param string $prefix Handle prefix. + * @param string $directory Theme-relative asset directory. + * @return void + */ + private function enqueue_styles( string $prefix, string $directory ): void { + foreach ( $this->asset_files( $directory, array( 'css' ) ) as $asset ) { + AssetEnqueuer::enqueue_style( $prefix, $asset ); + } + } + + /** + * Enqueues JavaScript files from a built asset directory. + * + * @param string $prefix Handle prefix. + * @param string $directory Theme-relative asset directory. + * @return void + */ + private function enqueue_scripts( string $prefix, string $directory ): void { + foreach ( $this->asset_files( $directory, array( 'js' ) ) as $asset ) { + AssetEnqueuer::enqueue_script( $prefix, $asset ); + } + } + + /** + * Finds built assets below a theme-relative directory. + * + * @param string $directory Theme-relative asset directory. + * @param array $extensions Allowed file extensions. + * @return array Built asset records. + */ + private function asset_files( string $directory, array $extensions ): array { + $manifest_assets = $this->manifest_asset_files( $directory, $extensions ); + $assets = null === $manifest_assets ? $this->scanned_asset_files( $directory, $extensions ) : $manifest_assets; + + /** + * Filters built asset files before they are enqueued. + * + * Child themes and project plugins can add, remove, or reorder records. + * Asset records should include path, relative, uri, and version keys. + * + * @param array $assets Built asset records. + * @param string $directory Theme-relative asset directory being scanned. + * @param array $extensions Allowed file extensions for the current enqueue pass. + */ + $filtered = apply_filters( 'emulsify_theme_asset_files', $assets, $directory, $extensions ); + + return is_array( $filtered ) ? $filtered : $assets; + } + + /** + * Gets manifest-backed assets when a manifest declares the current scope. + * + * @param string $directory Theme-relative asset directory. + * @param array $extensions Allowed file extensions. + * @return array|null Manifest-backed records, or null for scanner fallback. + */ + private function manifest_asset_files( string $directory, array $extensions ): ?array { + $scope = $this->manifest_scope( $directory ); + + if ( null === $scope ) { + return null; + } + + return $this->manifest->asset_records( $scope, $extensions ); + } + + /** + * Maps a scanned asset directory to a manifest scope. + * + * @param string $directory Theme-relative asset directory. + * @return string|null Manifest scope, or null when unsupported. + */ + private function manifest_scope( string $directory ): ?string { + $directory = trim( $directory, '/\\' ); + + if ( 'dist/global' === $directory ) { + return 'global'; + } + + if ( 'dist/components' === $directory ) { + return 'components'; + } + + return null; + } + + /** + * Finds built assets below a theme-relative directory through filesystem scans. + * + * @param string $directory Theme-relative asset directory. + * @param array $extensions Allowed file extensions. + * @return array Built asset records. + */ + private function scanned_asset_files( string $directory, array $extensions ): array { + $assets = array(); + $seen = array(); + + foreach ( $this->raw_asset_file_records( $directory ) as $file ) { + if ( ! $this->extension_allowed( $file, $extensions ) ) { + continue; + } + + $relative = $file['relative']; + + if ( $this->is_reserved_editor_asset( $directory, $relative ) ) { + // Editor-only assets are handled by Editor\Enhancements so they + // receive editor dependencies and configuration before enqueue. + continue; + } + + if ( $this->is_scoped_component_asset( $directory, $file ) ) { + // Component metadata can opt assets into block-scoped loading. Leave + // those files to the block registration callback instead of loading + // them globally from every request's component scanner pass. + continue; + } + + if ( isset( $seen[ $relative ] ) ) { + // Discovery roots are child-first. Once a relative path is seen, + // later parent files with the same name are intentional fallbacks + // and should not be enqueued twice. + continue; + } + + $seen[ $relative ] = true; + $assets[] = array( + 'path' => $file['path'], + 'priority' => $file['priority'], + 'relative' => $relative, + 'uri' => $file['root_uri'] . '/' . $relative, + 'version' => $this->version( $file['path'] ), + ); + } + + return FileDiscovery::sort_by_priority_and_relative( $assets ); + } + + /** + * Gets raw built asset file records for a theme-relative directory. + * + * @param string $directory Theme-relative asset directory. + * @return array Raw file records. + */ + private function raw_asset_file_records( string $directory ): array { + $key = trim( $directory, '/\\' ); + + if ( ! array_key_exists( $key, $this->asset_file_records ) ) { + $this->asset_file_records[ $key ] = FileDiscovery::file_records( $this->asset_roots( $directory ), array( 'css', 'js' ) ); + } + + return $this->asset_file_records[ $key ]; + } + + /** + * Checks whether a raw asset record matches the requested extension set. + * + * @param array $file Raw file discovery record. + * @param array $extensions Allowed file extensions. + * @return bool TRUE when the file extension is allowed. + */ + private function extension_allowed( array $file, array $extensions ): bool { + $extensions = $this->normalize_extensions( $extensions ); + + if ( empty( $extensions ) ) { + return true; + } + + $extension = strtolower( pathinfo( (string) ( $file['path'] ?? '' ), PATHINFO_EXTENSION ) ); + + return isset( $extensions[ $extension ] ); + } + + /** + * Normalizes extension filters. + * + * @param array $extensions Candidate extensions. + * @return array Normalized extensions keyed by extension. + */ + private function normalize_extensions( array $extensions ): array { + $normalized = array(); + + foreach ( $extensions as $extension ) { + if ( ! is_scalar( $extension ) ) { + continue; + } + + $extension = strtolower( ltrim( trim( (string) $extension ), '.' ) ); + + if ( '' !== $extension ) { + $normalized[ $extension ] = true; + } + } + + return $normalized; + } + + /** + * Gets child theme asset roots first, then parent theme fallbacks. + * + * @param string $directory Theme-relative asset directory. + * @return array Asset root records. + */ + private function asset_roots( string $directory ): array { + $directories = FileDiscovery::theme_roots( $directory, true ); + + /** + * Filters built asset directories before files are discovered. + * + * Root records should include absolute path, public uri, and priority + * keys. Lower priority values are enqueued first after duplicate relative + * paths are resolved child-first. + * + * @param array $directories Built asset directory records. + * @param string $directory Theme-relative asset directory being scanned. + */ + $filtered = apply_filters( 'emulsify_theme_asset_directories', $directories, $directory ); + + if ( is_array( $filtered ) ) { + $directories = $filtered; + } + + return FileDiscovery::normalize_roots( + $directories, + array( + 'require_uri' => true, + ) + ); + } + + /** + * Checks whether a built asset is reserved for editor-only loading. + * + * @param string $directory Theme-relative asset directory being scanned. + * @param string $relative Asset path relative to the built directory. + * @return bool TRUE when the editor service should own the asset. + */ + private function is_reserved_editor_asset( string $directory, string $relative ): bool { + return 'dist/global' === trim( $directory, '/\\' ) + && 0 === strpos( ltrim( $relative, '/\\' ), 'editor/' ); + } + + /** + * Checks whether a component asset is declared as block-scoped metadata. + * + * @param string $directory Theme-relative asset directory being scanned. + * @param array $file File discovery record. + * @return bool TRUE when the asset should not be globally loaded. + */ + private function is_scoped_component_asset( string $directory, array $file ): bool { + if ( 'dist/components' !== trim( $directory, '/\\' ) || empty( $file['path'] ) ) { + return false; + } + + $root_relative = isset( $file['relative'] ) && is_scalar( $file['relative'] ) + ? AssetRecord::normalize_relative_path( (string) $file['relative'] ) + : ''; + + if ( '' !== $root_relative && isset( $this->manifest_component_asset_paths()[ $root_relative ] ) ) { + return true; + } + + $component_dir = dirname( (string) $file['path'] ); + $relative = FileDiscovery::relative_path( $component_dir, (string) $file['path'] ); + $asset_paths = $this->component_asset_paths( $component_dir ); + + return isset( $asset_paths[ $relative ] ); + } + + /** + * Gets manifest-scoped component asset paths. + * + * @return array Scoped paths keyed by dist/components-relative path. + */ + private function manifest_component_asset_paths(): array { + if ( is_array( $this->manifest_component_asset_paths ) ) { + return $this->manifest_component_asset_paths; + } + + $this->manifest_component_asset_paths = $this->manifest->scoped_component_asset_paths(); + + return $this->manifest_component_asset_paths; + } + + /** + * Gets declared component metadata asset paths for a component directory. + * + * @param string $component_dir Absolute component directory path. + * @return array Declared asset paths keyed by relative path. + */ + private function component_asset_paths( string $component_dir ): array { + if ( isset( $this->component_asset_paths[ $component_dir ] ) ) { + return $this->component_asset_paths[ $component_dir ]; + } + + $paths = array(); + $metadata_files = glob( rtrim( $component_dir, '/\\' ) . '/*.component.json' ); + + if ( ! is_array( $metadata_files ) ) { + $this->component_asset_paths[ $component_dir ] = $paths; + return $paths; + } + + foreach ( $metadata_files as $metadata_file ) { + $contents = is_readable( $metadata_file ) ? file_get_contents( $metadata_file ) : false; + + if ( ! is_string( $contents ) ) { + continue; + } + + $metadata = json_decode( $contents, true ); + + if ( ! is_array( $metadata ) || JSON_ERROR_NONE !== json_last_error() || empty( $metadata['assets'] ) || ! is_array( $metadata['assets'] ) ) { + continue; + } + + foreach ( $this->metadata_asset_paths( $metadata['assets'] ) as $path ) { + $paths[ $path ] = true; + } + } + + $this->component_asset_paths[ $component_dir ] = $paths; + + return $paths; + } + + /** + * Collects asset paths from component metadata asset sections. + * + * @param array $assets Component metadata assets. + * @return array Relative asset paths. + */ + private function metadata_asset_paths( array $assets ): array { + $paths = array(); + + foreach ( array( 'frontend', 'editor' ) as $context ) { + if ( isset( $assets[ $context ] ) && is_array( $assets[ $context ] ) ) { + $paths = array_merge( $paths, $this->metadata_asset_paths( $assets[ $context ] ) ); + } + } + + foreach ( array( 'css', 'js' ) as $type ) { + if ( empty( $assets[ $type ] ) || ! is_array( $assets[ $type ] ) ) { + continue; + } + + foreach ( $assets[ $type ] as $entry ) { + $path = $this->metadata_asset_path( $entry ); + + if ( '' !== $path ) { + $paths[] = $path; + } + } + } + + return array_values( array_unique( $paths ) ); + } + + /** + * Gets one component metadata asset path. + * + * @param mixed $entry Metadata asset entry. + * @return string Relative asset path, or empty string when invalid. + */ + private function metadata_asset_path( $entry ): string { + $entry = is_array( $entry ) ? $entry : array( 'path' => $entry ); + + return AssetRecord::entry_path( $entry ); + } + + /** + * Gets a filemtime-based asset version. + * + * @param string $path Absolute file path. + * @return string|null Asset version. + */ + private function version( string $path ): ?string { + $modified = filemtime( $path ); + + return false === $modified ? null : (string) $modified; + } +} diff --git a/includes/Runtime/Context.php b/includes/Runtime/Context.php new file mode 100644 index 0000000..e90d59a --- /dev/null +++ b/includes/Runtime/Context.php @@ -0,0 +1,148 @@ +<?php +/** + * Adds global Timber context values. + * + * @package Emulsify + */ + +namespace Emulsify\Theme\Runtime; + +/** + * Timber context integration. + */ +final class Context { + + /** + * Registers context filters. + * + * @return void + */ + public function register(): void { + add_filter( 'timber/context', array( $this, 'add' ) ); + } + + /** + * Adds project-wide context values. + * + * @param array $context Timber context. + * @return array Updated Timber context. + */ + public function add( array $context ): array { + if ( empty( $context['site'] ) && class_exists( '\Timber\Site' ) ) { + // Timber usually supplies "site"; keep this fallback for smoke tests + // and custom contexts that invoke the filter before Timber populates it. + $context['site'] = new \Timber\Site(); + } + + $context['theme'] = $this->theme(); + $context['menu'] = $this->menu(); + $context['post'] = $context['post'] ?? $this->post(); + $context['wp'] = $this->wordpress_helpers(); + $context['body_class'] = $context['body_class'] ?? implode( ' ', get_body_class() ); + + /** + * Filters global Timber context values added by the parent theme. + * + * Child themes and project plugins can add project-wide values here + * without replacing the parent Context service. + * + * @param array $context Timber context values. + */ + $filtered = apply_filters( 'emulsify_theme_context', $context ); + + return is_array( $filtered ) ? $filtered : $context; + } + + /** + * Gets normalized theme metadata for Twig. + * + * @return array Theme metadata. + */ + private function theme(): array { + $theme = wp_get_theme(); + $parent = $theme->parent(); + + return array( + 'name' => $theme->get( 'Name' ), + 'version' => $theme->get( 'Version' ), + 'text_domain' => $theme->get( 'TextDomain' ), + 'link' => get_stylesheet_directory_uri(), + 'path' => get_stylesheet_directory(), + 'template_directory_uri' => get_template_directory_uri(), + 'template_directory' => get_template_directory(), + 'stylesheet_directory' => get_stylesheet_directory(), + 'parent' => $parent ? $parent->get( 'Name' ) : null, + ); + } + + /** + * Gets the primary Timber menu when one exists. + * + * @return mixed Timber menu or null. + */ + private function menu() { + if ( ! class_exists( '\Timber\Timber' ) ) { + return null; + } + + try { + if ( has_nav_menu( 'primary' ) ) { + // Prefer the registered primary location. Falling back to Timber's + // default menu keeps minimal installs from failing with no menu set. + return \Timber\Timber::get_menu( 'primary' ); + } + + return \Timber\Timber::get_menu(); + } catch ( \Throwable $throwable ) { + return null; + } + } + + /** + * Gets the current Timber post for singular requests. + * + * @return mixed Timber post or null. + */ + private function post() { + if ( ! is_singular() || ! class_exists( '\Timber\Timber' ) ) { + return null; + } + + try { + return \Timber\Timber::get_post(); + } catch ( \Throwable $throwable ) { + return null; + } + } + + /** + * Provides common WordPress values to Twig templates. + * + * @return array WordPress helper values. + */ + private function wordpress_helpers(): array { + return array( + 'ajax_url' => admin_url( 'admin-ajax.php' ), + 'body_class' => implode( ' ', get_body_class() ), + 'home_url' => home_url( '/' ), + 'is_404' => is_404(), + 'is_archive' => is_archive(), + 'is_front_page' => is_front_page(), + 'is_home' => is_home(), + 'is_page' => is_page(), + 'is_search' => is_search(), + 'is_single' => is_single(), + 'is_singular' => is_singular(), + 'is_user_logged_in' => is_user_logged_in(), + 'login_url' => wp_login_url(), + 'logout_url' => wp_logout_url(), + 'rest_url' => function_exists( 'rest_url' ) ? rest_url() : '', + 'search_query' => get_search_query(), + 'site_url' => site_url( '/' ), + 'stylesheet_directory_uri' => get_stylesheet_directory_uri(), + 'template_directory_uri' => get_template_directory_uri(), + 'theme_json' => file_exists( get_theme_file_path( 'theme.json' ) ), + 'user' => is_user_logged_in() ? wp_get_current_user() : null, + ); + } +} diff --git a/includes/Runtime/MissingTimber.php b/includes/Runtime/MissingTimber.php new file mode 100644 index 0000000..cac818f --- /dev/null +++ b/includes/Runtime/MissingTimber.php @@ -0,0 +1,96 @@ +<?php +/** + * Provides clear errors when Timber is unavailable. + * + * @package Emulsify + */ + +namespace Emulsify\Theme\Runtime; + +/** + * Missing Timber error handling. + */ +final class MissingTimber { + + /** + * Registers admin and runtime notices. + * + * @return void + */ + public function register(): void { + add_action( 'admin_notices', array( $this, 'admin_notice' ) ); + add_action( 'template_redirect', array( $this, 'runtime_error' ), 0 ); + } + + /** + * Displays an admin notice for users who can act on the dependency. + * + * @return void + */ + public function admin_notice(): void { + if ( ! current_user_can( 'activate_plugins' ) && ! current_user_can( 'switch_themes' ) ) { + return; + } + + printf( + '<div class="notice notice-error"><p>%s</p></div>', + esc_html( $this->message() ) + ); + } + + /** + * Stops frontend rendering before Timber templates are loaded. + * + * @return void + */ + public function runtime_error(): void { + if ( is_admin() || $this->is_ajax_request() || $this->is_json_request() ) { + return; + } + + wp_die( + wp_kses_post( + sprintf( + '<p>%s</p><p><code>%s</code></p>', + $this->message(), + 'composer require timber/timber' + ) + ), + esc_html__( 'Timber is required', 'emulsify' ), + array( 'response' => 500 ) + ); + } + + /** + * Gets the dependency error message. + * + * @return string Error message. + */ + private function message(): string { + return __( 'Emulsify requires Timber to render WordPress templates. Install Timber with Composer or activate the Timber plugin.', 'emulsify' ); + } + + /** + * Checks whether the current request is AJAX. + * + * @return bool TRUE for AJAX requests. + */ + private function is_ajax_request(): bool { + if ( function_exists( 'wp_doing_ajax' ) ) { + return wp_doing_ajax(); + } + + $doing_ajax = defined( 'DOING_AJAX' ) ? constant( 'DOING_AJAX' ) : false; + + return true === $doing_ajax; + } + + /** + * Checks whether the current request is JSON. + * + * @return bool TRUE for JSON requests. + */ + private function is_json_request(): bool { + return function_exists( 'wp_is_json_request' ) && wp_is_json_request(); + } +} diff --git a/includes/Runtime/Setup.php b/includes/Runtime/Setup.php new file mode 100644 index 0000000..ccdc717 --- /dev/null +++ b/includes/Runtime/Setup.php @@ -0,0 +1,136 @@ +<?php +/** + * Registers WordPress theme setup features. + * + * @package Emulsify + */ + +namespace Emulsify\Theme\Runtime; + +/** + * Theme setup hooks. + */ +final class Setup { + + /** + * Registers setup hooks. + * + * @return void + */ + public function register(): void { + add_action( 'after_setup_theme', array( $this, 'theme_supports' ) ); + add_action( 'after_setup_theme', array( $this, 'menus' ) ); + } + + /** + * Registers WordPress theme support. + * + * @return void + */ + public function theme_supports(): void { + $options = array( + 'image_sizes' => array( + 'emulsify-wide' => array( + 'width' => 1600, + 'height' => 900, + 'crop' => true, + ), + 'emulsify-card' => array( + 'width' => 768, + 'height' => 432, + 'crop' => true, + ), + ), + 'textdomain' => array( + 'domain' => 'emulsify', + 'path' => get_template_directory() . '/languages', + ), + 'theme_supports' => array( + 'automatic-feed-links' => true, + 'title-tag' => true, + 'post-thumbnails' => true, + 'custom-logo' => array( + 'flex-height' => true, + 'flex-width' => true, + ), + 'html5' => array( + 'caption', + 'comment-form', + 'comment-list', + 'gallery', + 'navigation-widgets', + 'script', + 'search-form', + 'style', + ), + 'align-wide' => true, + 'responsive-embeds' => true, + 'wp-block-styles' => true, + 'editor-styles' => true, + 'appearance-tools' => true, + ), + ); + + /** + * Filters parent theme setup options before they are registered. + * + * Options include textdomain, image_sizes, and theme_supports keys. + * Set a theme support value to false to skip registering that support. + * + * @param array $options Parent theme setup options. + */ + $filtered = apply_filters( 'emulsify_theme_setup_options', $options ); + + if ( is_array( $filtered ) ) { + $options = $filtered; + } + + if ( ! empty( $options['textdomain']['domain'] ) && ! empty( $options['textdomain']['path'] ) ) { + // The parent text domain stays "emulsify"; generated child themes can + // load their own text domain from child functions.php when needed. + load_theme_textdomain( (string) $options['textdomain']['domain'], (string) $options['textdomain']['path'] ); + } + + foreach ( $options['theme_supports'] ?? array() as $feature => $support_options ) { + if ( false === $support_options ) { + // A false value gives child themes a simple way to opt out of one + // parent support without replacing the full setup service. + continue; + } + + if ( true === $support_options ) { + add_theme_support( (string) $feature ); + continue; + } + + add_theme_support( (string) $feature, $support_options ); + } + + foreach ( $options['image_sizes'] ?? array() as $name => $image_size ) { + if ( ! is_array( $image_size ) ) { + continue; + } + + add_image_size( + (string) $name, + isset( $image_size['width'] ) ? (int) $image_size['width'] : 0, + isset( $image_size['height'] ) ? (int) $image_size['height'] : 0, + $image_size['crop'] ?? false + ); + } + } + + /** + * Registers navigation menu locations. + * + * @return void + */ + public function menus(): void { + register_nav_menus( + array( + 'primary' => __( 'Primary Navigation', 'emulsify' ), + 'footer' => __( 'Footer Navigation', 'emulsify' ), + ) + ); + } +} diff --git a/includes/Runtime/TimberIntegration.php b/includes/Runtime/TimberIntegration.php new file mode 100644 index 0000000..195495f --- /dev/null +++ b/includes/Runtime/TimberIntegration.php @@ -0,0 +1,42 @@ +<?php +/** + * Initializes Timber when it is available. + * + * @package Emulsify + */ + +namespace Emulsify\Theme\Runtime; + +/** + * Timber runtime guard. + */ +final class TimberIntegration { + + /** + * Initializes Timber. + * + * @return bool TRUE when Timber is available and initialized. + */ + public function register(): bool { + if ( ! $this->is_available() ) { + return false; + } + + try { + \Timber\Timber::init(); + } catch ( \Throwable $throwable ) { + return false; + } + + return true; + } + + /** + * Checks whether Timber is loaded by Composer or a plugin. + * + * @return bool TRUE when Timber can be used. + */ + private function is_available(): bool { + return class_exists( '\Timber\Timber' ); + } +} diff --git a/includes/Runtime/Twig.php b/includes/Runtime/Twig.php new file mode 100644 index 0000000..8fe3e40 --- /dev/null +++ b/includes/Runtime/Twig.php @@ -0,0 +1,485 @@ +<?php +/** + * Registers Twig loader paths and helpers. + * + * @package Emulsify + */ + +namespace Emulsify\Theme\Runtime; + +use Emulsify\Theme\Support\AttributeBag; +use Emulsify\Theme\Support\FileDiscovery; +use Emulsify\Theme\Support\ProjectConfig; +use Emulsify\Theme\Twig\ProjectComponentLoader; +use Emulsify\Theme\Twig\SwitchExtension; + +/** + * Twig integration for Timber. + */ +final class Twig { + + /** + * Registers Timber Twig hooks. + * + * @return void + */ + public function register(): void { + add_filter( 'timber/loader/loader', array( $this, 'loader_paths' ) ); + add_filter( 'timber/twig/functions', array( $this, 'functions' ) ); + add_filter( 'timber/twig', array( $this, 'extensions' ) ); + } + + /** + * Adds theme paths and namespaces to the Twig loader. + * + * @param mixed $loader Timber loader. + * @return mixed Updated Timber loader. + */ + public function loader_paths( $loader ) { + if ( ! is_object( $loader ) || ! method_exists( $loader, 'addPath' ) ) { + return $loader; + } + + $namespaces = array_merge( + array( + array( + 'namespace' => 'templates', + 'path' => get_stylesheet_directory() . '/templates', + ), + array( + 'namespace' => 'templates', + 'path' => get_template_directory() . '/templates', + ), + array( + 'namespace' => 'emulsify-tpl', + 'path' => get_template_directory() . '/templates', + ), + ), + $this->project_structure_namespaces(), + array( + // @components remains a compatibility namespace for component + // systems and existing projects that have not moved to + // project_machine_name:component references. + array( + 'namespace' => 'components', + 'path' => get_stylesheet_directory() . '/src/components', + ), + array( + 'namespace' => 'components', + 'path' => get_stylesheet_directory() . '/components', + ), + array( + 'namespace' => 'components', + 'path' => get_template_directory() . '/src/components', + ), + array( + 'namespace' => 'components', + 'path' => get_template_directory() . '/components', + ), + ) + ); + + /** + * Filters Twig namespace paths before they are added to Timber. + * + * Namespace records should include path and namespace keys. Existing + * child-first order is preserved unless a filter intentionally changes it. + * + * @param array $namespaces Twig namespace path records. + * @param mixed $loader Timber loader instance. + */ + $filtered = apply_filters( 'emulsify_theme_twig_namespaces', $namespaces, $loader ); + + if ( is_array( $filtered ) ) { + $namespaces = $filtered; + } + + foreach ( $namespaces as $namespace ) { + if ( ! is_array( $namespace ) || empty( $namespace['path'] ) || empty( $namespace['namespace'] ) ) { + continue; + } + + $this->add_path( $loader, (string) $namespace['path'], (string) $namespace['namespace'] ); + } + + return $this->with_project_component_loader( $loader ); + } + + /** + * Registers Twig functions. + * + * @param array $functions Existing Timber functions. + * @return array Updated Timber functions. + */ + public function functions( array $functions ): array { + $functions['bem'] = array( + 'callable' => array( $this, 'bem' ), + 'needs_context' => true, + 'is_safe' => array( 'html' ), + ); + + $functions['add_attributes'] = array( + 'callable' => array( $this, 'add_attributes' ), + 'needs_context' => true, + 'is_safe' => array( 'html' ), + ); + + return $functions; + } + + /** + * Registers custom Twig extensions. + * + * @param \Twig\Environment $environment Timber Twig environment. + * @return \Twig\Environment Updated Twig environment. + */ + public function extensions( \Twig\Environment $environment ): \Twig\Environment { + $environment->addExtension( new SwitchExtension() ); + + return $environment; + } + + /** + * Builds a BEM class attribute. + * + * @param mixed ...$arguments Core-style BEM arguments. + * @return AttributeBag HTML attributes. + */ + public function bem( ...$arguments ): AttributeBag { + $context = $this->shift_twig_context( $arguments ); + $options = $this->normalize_bem_options( + $arguments[0] ?? '', + $arguments[1] ?? array(), + $arguments[2] ?? '', + $arguments[3] ?? array(), + $arguments[4] ?? array() + ); + $base_class = trim( (string) $options['base_class'] ); + $blockname = trim( (string) $options['blockname'] ); + $classes = array(); + + if ( '' !== $base_class ) { + $class_prefix = '' !== $blockname ? $blockname . '__' . $base_class : $base_class; + $classes[] = $class_prefix; + + foreach ( $this->normalize_list( $options['modifiers'] ) as $modifier ) { + $classes[] = $class_prefix . '--' . $modifier; + } + } + + $classes = array_merge( $classes, $this->normalize_list( $options['extra'] ) ); + + $attribute_bag = new AttributeBag( $options['attributes'] ); + $attribute_bag->addClass( $classes ); + $attribute_bag->merge( $this->attributes_from_context( $context ) ); + + return $attribute_bag; + } + + /** + * Builds HTML attributes from an associative array. + * + * @param mixed ...$arguments Attribute arguments. + * @return AttributeBag HTML attributes. + */ + public function add_attributes( ...$arguments ): AttributeBag { + $context = $this->shift_twig_context( $arguments ); + $attribute_bag = $this->attributes_from_context( $context ); + $additional = $arguments[0] ?? array(); + $legacy_context = array(); + + if ( $this->is_twig_context( $additional ) && isset( $arguments[1] ) ) { + $legacy_context = $additional; + $additional = $arguments[1]; + } + + if ( ! empty( $legacy_context ) ) { + $attribute_bag->merge( $this->attributes_from_context( $legacy_context ) ); + } + + $attribute_bag->merge( $additional ); + + return $attribute_bag; + } + + /** + * Adds a Twig path when it exists. + * + * @param mixed $loader Timber loader. + * @param string $path Filesystem path. + * @param string $namespace Twig namespace. + * @return void + */ + private function add_path( $loader, string $path, string $namespace ): void { + if ( is_dir( $path ) && is_readable( $path ) ) { + $loader->addPath( $path, $namespace ); + } + } + + /** + * Gets Twig namespace records declared by project.emulsify.json. + * + * Emulsify Core already supports variant.structureImplementations for + * component-system roots. Honor the same config here so Storybook/Core and + * WordPress runtime Twig resolution stay aligned. + * + * @return array Twig namespace path records. + */ + private function project_structure_namespaces(): array { + $records = array(); + $theme_dir = rtrim( get_stylesheet_directory(), '/\\' ); + + foreach ( ProjectConfig::structure_implementations() as $implementation ) { + if ( ! is_array( $implementation ) || empty( $implementation['name'] ) || empty( $implementation['directory'] ) ) { + continue; + } + + $namespace = ltrim( (string) $implementation['name'], '@' ); + + if ( 1 !== preg_match( '/^[A-Za-z0-9_-]+$/', $namespace ) ) { + continue; + } + + $resolved = ProjectConfig::resolve_theme_relative_path( $theme_dir, (string) $implementation['directory'] ); + + if ( '' === $resolved ) { + continue; + } + + $records[] = array( + 'namespace' => $namespace, + 'path' => $resolved, + ); + } + + return $records; + } + + /** + * Wraps the Timber loader with project machine-name component support. + * + * @param mixed $loader Timber loader. + * @return mixed Updated Timber loader. + */ + private function with_project_component_loader( $loader ) { + if ( ! $this->can_wrap_twig_loader( $loader ) ) { + return $loader; + } + + $machine_name = ProjectConfig::machine_name(); + + if ( '' === $machine_name ) { + return $loader; + } + + $roots = $this->project_component_roots( $machine_name, $loader ); + + if ( empty( $roots ) ) { + return $loader; + } + + return new ProjectComponentLoader( $machine_name, $roots, $loader ); + } + + /** + * Checks whether the current loader can be wrapped safely. + * + * @param mixed $loader Timber loader. + * @return bool TRUE when Twig loader classes are available. + */ + private function can_wrap_twig_loader( $loader ): bool { + return interface_exists( '\Twig\Loader\LoaderInterface' ) + && class_exists( '\Twig\Source' ) + && class_exists( '\Twig\Error\LoaderError' ) + && is_a( $loader, '\Twig\Loader\LoaderInterface' ); + } + + /** + * Gets project component root paths in child-first order. + * + * @param string $machine_name Active project machine name. + * @param mixed $loader Timber loader. + * @return array Component root paths. + */ + private function project_component_roots( string $machine_name, $loader ): array { + $roots = array( + array( + 'path' => get_stylesheet_directory() . '/src/components', + 'source' => 'child', + ), + array( + 'path' => get_stylesheet_directory() . '/components', + 'source' => 'child', + ), + array( + 'path' => get_template_directory() . '/src/components', + 'source' => 'parent', + ), + array( + 'path' => get_template_directory() . '/components', + 'source' => 'parent', + ), + ); + + /** + * Filters roots used for machineName:component Twig references. + * + * Roots can be path strings or arrays with a path key. The default order + * mirrors @components: active child paths first, then parent paths. + * + * @param array $roots Project component root records. + * @param string $machine_name Active project machine name. + * @param mixed $loader Timber loader instance. + */ + $filtered = apply_filters( 'emulsify_theme_project_component_roots', $roots, $machine_name, $loader ); + + if ( is_array( $filtered ) ) { + $roots = $filtered; + } + + return $this->normalize_project_component_roots( $roots ); + } + + /** + * Normalizes component root records to readable unique paths. + * + * @param array $roots Component root records. + * @return array Component root paths. + */ + private function normalize_project_component_roots( array $roots ): array { + return array_column( FileDiscovery::normalize_roots( $roots ), 'path' ); + } + + /** + * Normalizes positional and object-style BEM arguments. + * + * @param mixed $base_class Base class or options object. + * @param mixed $modifiers Modifier values. + * @param mixed $blockname Block name. + * @param mixed $extra Extra classes. + * @param mixed $attributes Extra attributes. + * @return array Normalized BEM options. + */ + private function normalize_bem_options( $base_class, $modifiers, $blockname, $extra, $attributes ): array { + if ( ! is_array( $base_class ) && ! is_object( $base_class ) ) { + return array( + 'base_class' => $base_class, + 'modifiers' => $modifiers, + 'blockname' => $blockname, + 'extra' => $extra, + 'attributes' => $attributes, + ); + } + + $options = is_array( $base_class ) ? $base_class : get_object_vars( $base_class ); + $has_bem_object_shape = $this->has_non_empty_option( $options, 'block' ) && $this->has_non_empty_option( $options, 'element' ); + + return array( + 'base_class' => $this->first_option( $options, array( 'baseClass', 'base_class', 'base' ), $has_bem_object_shape ? $options['element'] : ( $options['block'] ?? '' ) ), + 'modifiers' => $options['modifiers'] ?? array(), + 'blockname' => $this->first_option( $options, array( 'blockname', 'blockName' ), $has_bem_object_shape ? $options['block'] : ( $options['element'] ?? '' ) ), + 'extra' => $options['extra'] ?? array(), + 'attributes' => $options['attributes'] ?? array(), + ); + } + + /** + * Removes a Timber context argument from a Twig callback argument list. + * + * @param array $arguments Function arguments. + * @return array Timber context. + */ + private function shift_twig_context( array &$arguments ): array { + if ( count( $arguments ) > 1 && is_array( $arguments[0] ) ) { + return array_shift( $arguments ); + } + + return array(); + } + + /** + * Checks whether a value looks like a Timber context array. + * + * @param mixed $value Value to inspect. + * @return bool TRUE when the value looks like Timber context. + */ + private function is_twig_context( $value ): bool { + if ( ! is_array( $value ) ) { + return false; + } + + foreach ( array( 'attributes', 'site', 'theme', 'post', 'wp' ) as $key ) { + if ( array_key_exists( $key, $value ) ) { + return true; + } + } + + return false; + } + + /** + * Builds an AttributeBag from Timber context attributes. + * + * @param array $context Timber context. + * @return AttributeBag Context attributes. + */ + private function attributes_from_context( array $context ): AttributeBag { + return new AttributeBag( $context['attributes'] ?? array() ); + } + + /** + * Gets the first non-empty option value. + * + * @param array $options Option map. + * @param array $keys Candidate keys. + * @param mixed $default Default value. + * @return mixed Option value. + */ + private function first_option( array $options, array $keys, $default = null ) { + foreach ( $keys as $key ) { + if ( $this->has_non_empty_option( $options, $key ) ) { + return $options[ $key ]; + } + } + + return $default; + } + + /** + * Checks whether an option key contains a non-empty value. + * + * @param array $options Option map. + * @param string $key Option key. + * @return bool TRUE when set and non-empty. + */ + private function has_non_empty_option( array $options, string $key ): bool { + return array_key_exists( $key, $options ) && null !== $options[ $key ] && '' !== $options[ $key ] && false !== $options[ $key ]; + } + + /** + * Normalizes class-like values into a flat list. + * + * @param mixed $value Class value. + * @return array Class list. + */ + private function normalize_list( $value ): array { + $items = array(); + + if ( is_array( $value ) ) { + foreach ( $value as $item ) { + $items = array_merge( $items, $this->normalize_list( $item ) ); + } + return $items; + } + + if ( is_scalar( $value ) ) { + $value = trim( (string) $value ); + + if ( '' !== $value ) { + $items[] = $value; + } + } + + return $items; + } +} diff --git a/includes/Support/AssetEnqueuer.php b/includes/Support/AssetEnqueuer.php new file mode 100644 index 0000000..6cd4466 --- /dev/null +++ b/includes/Support/AssetEnqueuer.php @@ -0,0 +1,129 @@ +<?php +/** + * Shared asset enqueue helpers. + * + * @package Emulsify + */ + +namespace Emulsify\Theme\Support; + +/** + * Builds handles and enqueues asset records. + */ +final class AssetEnqueuer { + + /** + * Builds a WordPress-safe asset handle. + * + * @param string $prefix Handle prefix. + * @param string|array $record Relative path or asset record. + * @return string Asset handle. + */ + public static function handle( string $prefix, $record ): string { + if ( is_array( $record ) && ! empty( $record['handle'] ) && is_scalar( $record['handle'] ) ) { + return self::sanitize_key( (string) $record['handle'] ); + } + + if ( is_array( $record ) ) { + $relative = isset( $record['relative'] ) && is_scalar( $record['relative'] ) + ? (string) $record['relative'] + : basename( (string) ( $record['path'] ?? 'asset' ) ); + } else { + $relative = is_scalar( $record ) ? (string) $record : 'asset'; + } + + $name = preg_replace( '/\.(css|js)$/', '', $relative ); + $name = preg_replace( '/[^A-Za-z0-9_-]+/', '-', (string) $name ); + + return self::sanitize_key( $prefix . '-' . trim( (string) $name, '-' ) ); + } + + /** + * Enqueues a style record. + * + * @param string $prefix Handle prefix. + * @param array $record Asset record. + * @return void + */ + public static function enqueue_style( string $prefix, array $record ): void { + if ( empty( $record['uri'] ) || ! function_exists( 'wp_enqueue_style' ) ) { + return; + } + + wp_enqueue_style( + self::handle( $prefix, $record ), + (string) $record['uri'], + self::dependencies( $record ), + $record['version'] ?? null + ); + } + + /** + * Enqueues a script record. + * + * @param string $prefix Handle prefix. + * @param array $record Asset record. + * @return void + */ + public static function enqueue_script( string $prefix, array $record ): void { + if ( empty( $record['uri'] ) ) { + return; + } + + $handle = self::handle( $prefix, $record ); + + if ( self::is_module_script( $record ) && function_exists( 'wp_enqueue_script_module' ) ) { + wp_enqueue_script_module( + $handle, + (string) $record['uri'], + self::dependencies( $record ), + $record['version'] ?? null + ); + return; + } + + if ( ! function_exists( 'wp_enqueue_script' ) ) { + return; + } + + wp_enqueue_script( + $handle, + (string) $record['uri'], + self::dependencies( $record ), + $record['version'] ?? null, + array( + 'in_footer' => true, + ) + ); + } + + /** + * Gets dependency handles from an asset record. + * + * @param array $record Asset record. + * @return array Dependency handles. + */ + public static function dependencies( array $record ): array { + return isset( $record['dependencies'] ) && is_array( $record['dependencies'] ) ? $record['dependencies'] : array(); + } + + /** + * Checks whether a script should be enqueued as a module. + * + * @param array $record Asset record. + * @return bool TRUE when the script is a module. + */ + public static function is_module_script( array $record ): bool { + return ! array_key_exists( 'module', $record ) || null === $record['module'] ? true : (bool) $record['module']; + } + + /** + * Sanitizes a WordPress asset handle. + * + * @param string $handle Raw handle. + * @return string Sanitized handle. + */ + private static function sanitize_key( string $handle ): string { + return function_exists( 'sanitize_key' ) ? sanitize_key( $handle ) : strtolower( preg_replace( '/[^a-z0-9_-]+/', '', $handle ) ); + } +} diff --git a/includes/Support/AssetManifest.php b/includes/Support/AssetManifest.php new file mode 100644 index 0000000..69cb469 --- /dev/null +++ b/includes/Support/AssetManifest.php @@ -0,0 +1,539 @@ +<?php +/** + * Optional built asset manifest reader. + * + * @package Emulsify + */ + +namespace Emulsify\Theme\Support; + +/** + * Reads child-first asset manifests and normalizes asset records. + */ +final class AssetManifest { + + /** + * Default theme-relative manifest path. + * + * @var string + */ + private const DEFAULT_PATH = 'dist/emulsify-assets.json'; + + /** + * Memoized manifest record. + * + * @var array|null + */ + private $manifest; + + /** + * Whether manifest resolution has already run for this instance. + * + * @var bool + */ + private $manifest_resolved = false; + + /** + * Gets manifest-backed asset records for a runtime scope. + * + * A null return means no usable manifest data was found for the scope and + * callers should keep their existing filesystem scanner fallback. + * + * @param string $scope Asset scope: global, editor, or components. + * @param array $extensions Allowed file extensions. + * @return array|null Asset records, or null when scanner fallback should run. + */ + public function asset_records( string $scope, array $extensions ): ?array { + $manifest = $this->manifest(); + + if ( null === $manifest ) { + return null; + } + + $records = $this->scope_records( $scope, $extensions, $this->asset_data( $manifest ), $manifest ); + + if ( null === $records ) { + return null; + } + + return FileDiscovery::sort_by_priority_and_relative( $records ); + } + + /** + * Gets manifest-backed asset records for specific block/component keys. + * + * A null return means the manifest does not declare any matching scoped + * assets and callers may use component metadata or scanner fallbacks. + * + * @param array $identifiers Block and component identifiers. + * @return array|null Asset records grouped by frontend/editor and css/js. + */ + public function scoped_asset_records( array $identifiers ): ?array { + $manifest = $this->manifest(); + + if ( null === $manifest ) { + return null; + } + + $assets = $this->asset_data( $manifest ); + $identifiers = $this->normalize_identifiers( $identifiers ); + $records = AssetRecord::empty_context_records(); + $declared = false; + + foreach ( $identifiers as $identifier ) { + if ( isset( $assets['blocks'] ) && is_array( $assets['blocks'] ) && array_key_exists( $identifier, $assets['blocks'] ) ) { + $declared = true; + $records = AssetRecord::merge_context_records( + $records, + $this->context_section_records( $assets['blocks'][ $identifier ], $manifest, $identifier ) + ); + } + + if ( isset( $assets['components'] ) && is_array( $assets['components'] ) && array_key_exists( $identifier, $assets['components'] ) ) { + $declared = true; + $records = AssetRecord::merge_context_records( + $records, + $this->context_section_records( $assets['components'][ $identifier ], $manifest, $identifier ) + ); + } + } + + return $declared ? AssetRecord::sort_context_records( $records ) : null; + } + + /** + * Gets manifest-scoped component asset paths. + * + * These paths are relative to dist/components and are used by the recursive + * scanner to avoid globally loading files that a block/component manifest + * entry will load only when the block renders. + * + * @return array Scoped component asset paths keyed by relative path. + */ + public function scoped_component_asset_paths(): array { + $manifest = $this->manifest(); + + if ( null === $manifest ) { + return array(); + } + + $assets = $this->asset_data( $manifest ); + $paths = array(); + + if ( isset( $assets['blocks'] ) && is_array( $assets['blocks'] ) ) { + foreach ( $assets['blocks'] as $section ) { + foreach ( $this->context_section_paths( $section ) as $path ) { + $paths[ $path ] = true; + } + } + } + + if ( isset( $assets['components'] ) && is_array( $assets['components'] ) && ! $this->is_asset_section( $assets['components'] ) ) { + foreach ( $assets['components'] as $section ) { + foreach ( $this->context_section_paths( $section ) as $path ) { + $paths[ $path ] = true; + } + } + } + + return $paths; + } + + /** + * Gets records for a manifest asset scope. + * + * @param string $scope Asset scope. + * @param array $extensions Allowed file extensions. + * @param array $assets Manifest asset data. + * @param array $manifest Active manifest record. + * @return array|null Asset records, or null when the scope is undeclared. + */ + private function scope_records( string $scope, array $extensions, array $assets, array $manifest ): ?array { + if ( 'components' === $scope ) { + $declared = false; + $records = array(); + + if ( array_key_exists( 'components', $assets ) ) { + $declared = true; + $records = $this->is_asset_section( $assets['components'] ) + ? array_merge( $records, $this->section_records( $assets['components'], $extensions, $manifest ) ) + : $records; + } + + return $declared ? $records : null; + } + + if ( ! array_key_exists( $scope, $assets ) ) { + return null; + } + + return $this->section_records( $assets[ $scope ], $extensions, $manifest ); + } + + /** + * Gets normalized records from a manifest section. + * + * @param mixed $section Manifest section. + * @param array $extensions Allowed file extensions. + * @param array $manifest Active manifest record. + * @param string $block_name Optional block name for block-specific entries. + * @return array Asset records. + */ + private function section_records( $section, array $extensions, array $manifest, string $block_name = '' ): array { + if ( ! is_array( $section ) ) { + return array(); + } + + $records = array(); + + foreach ( $extensions as $extension ) { + if ( ! is_scalar( $extension ) ) { + continue; + } + + $key = strtolower( ltrim( trim( (string) $extension ), '.' ) ); + + if ( '' === $key || empty( $section[ $key ] ) || ! is_array( $section[ $key ] ) ) { + continue; + } + + foreach ( $section[ $key ] as $entry ) { + $record = $this->asset_record( $entry, $key, $manifest, $block_name ); + + if ( null !== $record ) { + $records[] = $record; + } + } + } + + return $records; + } + + /** + * Gets frontend/editor records from a manifest section. + * + * @param mixed $section Manifest section. + * @param array $manifest Active manifest record. + * @param string $block_name Optional block/component identifier. + * @return array Context asset records. + */ + private function context_section_records( $section, array $manifest, string $block_name = '' ): array { + $records = AssetRecord::empty_context_records(); + + if ( ! is_array( $section ) ) { + return $records; + } + + if ( isset( $section['frontend'] ) || isset( $section['editor'] ) ) { + if ( isset( $section['frontend'] ) && is_array( $section['frontend'] ) ) { + $records['frontend']['css'] = $this->section_records( $section['frontend'], array( 'css' ), $manifest, $block_name ); + $records['frontend']['js'] = $this->section_records( $section['frontend'], array( 'js' ), $manifest, $block_name ); + } + + if ( isset( $section['editor'] ) && is_array( $section['editor'] ) ) { + $records['editor']['css'] = $this->section_records( $section['editor'], array( 'css' ), $manifest, $block_name ); + $records['editor']['js'] = $this->section_records( $section['editor'], array( 'js' ), $manifest, $block_name ); + } + + return $records; + } + + $records['frontend']['css'] = $this->section_records( $section, array( 'css' ), $manifest, $block_name ); + $records['frontend']['js'] = $this->section_records( $section, array( 'js' ), $manifest, $block_name ); + + return $records; + } + + /** + * Gets scoped asset paths from a frontend/editor manifest section. + * + * @param mixed $section Manifest section. + * @return array Component-relative paths. + */ + private function context_section_paths( $section ): array { + if ( ! is_array( $section ) ) { + return array(); + } + + $paths = array(); + + if ( isset( $section['frontend'] ) || isset( $section['editor'] ) ) { + foreach ( array( 'frontend', 'editor' ) as $context ) { + if ( isset( $section[ $context ] ) && is_array( $section[ $context ] ) ) { + $paths = array_merge( $paths, $this->section_paths( $section[ $context ] ) ); + } + } + + return array_values( array_unique( $paths ) ); + } + + return $this->section_paths( $section ); + } + + /** + * Gets scoped asset paths from a css/js manifest section. + * + * @param array $section Manifest section. + * @return array Component-relative paths. + */ + private function section_paths( array $section ): array { + $paths = array(); + + foreach ( array( 'css', 'js' ) as $type ) { + if ( empty( $section[ $type ] ) || ! is_array( $section[ $type ] ) ) { + continue; + } + + foreach ( $section[ $type ] as $entry ) { + $data = is_array( $entry ) ? $entry : array( 'path' => $entry ); + $path = $this->component_relative_path( AssetRecord::entry_path( $data ) ); + + if ( '' !== $path ) { + $paths[] = $path; + } + } + } + + return array_values( array_unique( $paths ) ); + } + + /** + * Converts a manifest entry path into a dist/components-relative path. + * + * @param string $path Manifest entry path. + * @return string Component-relative path. + */ + private function component_relative_path( string $path ): string { + if ( 0 === strpos( $path, 'components/' ) ) { + return substr( $path, strlen( 'components/' ) ); + } + + return $path; + } + + /** + * Gets manifest asset data. + * + * @param array $manifest Active manifest record. + * @return array Asset data. + */ + private function asset_data( array $manifest ): array { + return isset( $manifest['data']['assets'] ) && is_array( $manifest['data']['assets'] ) + ? $manifest['data']['assets'] + : $manifest['data']; + } + + /** + * Checks whether a section directly declares css/js records. + * + * @param mixed $section Manifest section. + * @return bool TRUE when the section is a broad asset section. + */ + private function is_asset_section( $section ): bool { + return is_array( $section ) && ( array_key_exists( 'css', $section ) || array_key_exists( 'js', $section ) ); + } + + /** + * Normalizes scoped asset identifiers. + * + * @param array $identifiers Candidate identifiers. + * @return array Normalized identifiers. + */ + private function normalize_identifiers( array $identifiers ): array { + $normalized = array(); + + foreach ( $identifiers as $identifier ) { + if ( ! is_scalar( $identifier ) ) { + continue; + } + + $identifier = trim( (string) $identifier ); + + if ( '' !== $identifier ) { + $normalized[] = $identifier; + } + } + + return array_values( array_unique( $normalized ) ); + } + + /** + * Normalizes a manifest asset entry. + * + * @param mixed $entry Manifest asset entry. + * @param string $extension Expected file extension. + * @param array $manifest Active manifest record. + * @param string $block_name Optional block name for block-specific entries. + * @return array|null Asset record, or null when invalid. + */ + private function asset_record( $entry, string $extension, array $manifest, string $block_name = '' ): ?array { + $data = is_array( $entry ) ? $entry : array( 'path' => $entry ); + $relative = AssetRecord::entry_path( $data ); + + if ( '' === $relative || strtolower( pathinfo( $relative, PATHINFO_EXTENSION ) ) !== $extension ) { + return null; + } + + $path = $manifest['base_path'] . '/' . $relative; + + if ( ! is_readable( $path ) ) { + return null; + } + + $record = array( + 'path' => $path, + 'priority' => $manifest['priority'], + 'relative' => AssetRecord::entry_relative( $data, $relative ), + 'uri' => $manifest['base_uri'] . '/' . $relative, + 'version' => AssetRecord::entry_version( $data, $path ), + 'dependencies' => AssetRecord::entry_dependencies( $data ), + 'module' => AssetRecord::entry_module( $data ), + 'source' => $manifest['source'], + 'manifest_path' => $manifest['path'], + ); + + if ( '' !== $block_name ) { + $record['block'] = $block_name; + } + + return $record; + } + + /** + * Gets the first readable, valid child-first manifest. + * + * @return array|null Active manifest record, or null when unavailable. + */ + private function manifest(): ?array { + if ( $this->manifest_resolved ) { + return $this->manifest; + } + + $this->manifest_resolved = true; + $this->manifest = $this->resolve_manifest(); + + return $this->manifest; + } + + /** + * Resolves the first readable, valid child-first manifest. + * + * @return array|null Active manifest record, or null when unavailable. + */ + private function resolve_manifest(): ?array { + foreach ( $this->manifest_candidates() as $candidate ) { + if ( ! is_readable( $candidate['path'] ) ) { + continue; + } + + $contents = file_get_contents( $candidate['path'] ); + + if ( ! is_string( $contents ) ) { + return null; + } + + $data = json_decode( $contents, true ); + + if ( ! is_array( $data ) || JSON_ERROR_NONE !== json_last_error() ) { + return null; + } + + /** + * Filters parsed asset manifest data before records are created. + * + * Return a non-array value to ignore the manifest and fall back to + * the recursive scanner for the current request. + * + * @param array $data Parsed manifest data. + * @param array $candidate Manifest file candidate. + */ + $filtered = apply_filters( 'emulsify_theme_asset_manifest_data', $data, $candidate ); + + if ( ! is_array( $filtered ) ) { + return null; + } + + $candidate['data'] = $filtered; + + return $candidate; + } + + return null; + } + + /** + * Gets child-first manifest candidates. + * + * @return array Manifest file candidates. + */ + private function manifest_candidates(): array { + $relative_path = self::DEFAULT_PATH; + + /** + * Filters the theme-relative asset manifest path. + * + * Return an empty value to disable manifest loading and always use the + * recursive scanner fallback. + * + * @param string $relative_path Theme-relative manifest path. + */ + $filtered = apply_filters( 'emulsify_theme_asset_manifest_path', $relative_path ); + + if ( ! is_scalar( $filtered ) ) { + return array(); + } + + $relative_path = AssetRecord::normalize_relative_path( (string) $filtered ); + + if ( '' === $relative_path ) { + return array(); + } + + $candidates = array(); + + if ( function_exists( 'get_stylesheet_directory' ) && function_exists( 'get_stylesheet_directory_uri' ) ) { + $candidates[] = $this->candidate( + get_stylesheet_directory(), + get_stylesheet_directory_uri(), + $relative_path, + 0, + 'child' + ); + } + + if ( function_exists( 'get_template_directory' ) && function_exists( 'get_template_directory_uri' ) ) { + $candidates[] = $this->candidate( + get_template_directory(), + get_template_directory_uri(), + $relative_path, + 1, + 'parent' + ); + } + + return $candidates; + } + + /** + * Builds a manifest candidate record. + * + * @param string $theme_path Theme root path. + * @param string $theme_uri Theme root URI. + * @param string $relative_path Theme-relative manifest path. + * @param int $priority Root priority. + * @param string $source Root source. + * @return array Manifest candidate record. + */ + private function candidate( string $theme_path, string $theme_uri, string $relative_path, int $priority, string $source ): array { + $path = rtrim( $theme_path, '/\\' ) . '/' . $relative_path; + $uri = rtrim( $theme_uri, '/' ) . '/' . $relative_path; + + return array( + 'path' => $path, + 'base_path' => dirname( $path ), + 'base_uri' => rtrim( str_replace( '\\', '/', dirname( $uri ) ), '/' ), + 'priority' => $priority, + 'source' => $source, + ); + } +} diff --git a/includes/Support/AssetRecord.php b/includes/Support/AssetRecord.php new file mode 100644 index 0000000..d53efc9 --- /dev/null +++ b/includes/Support/AssetRecord.php @@ -0,0 +1,235 @@ +<?php +/** + * Shared asset record helpers. + * + * @package Emulsify + */ + +namespace Emulsify\Theme\Support; + +/** + * Normalizes built asset records and scoped context groups. + */ +final class AssetRecord { + + /** + * Gets an asset entry path. + * + * @param array $entry Asset entry. + * @return string Relative path. + */ + public static function entry_path( array $entry ): string { + foreach ( array( 'path', 'file', 'src', 'href' ) as $key ) { + if ( isset( $entry[ $key ] ) && is_scalar( $entry[ $key ] ) ) { + return self::normalize_relative_path( (string) $entry[ $key ] ); + } + } + + return ''; + } + + /** + * Gets the record-relative path. + * + * @param array $entry Asset entry. + * @param string $fallback Fallback relative path. + * @return string Relative path. + */ + public static function entry_relative( array $entry, string $fallback ): string { + if ( isset( $entry['relative'] ) && is_scalar( $entry['relative'] ) ) { + $relative = self::normalize_relative_path( (string) $entry['relative'] ); + + if ( '' !== $relative ) { + return $relative; + } + } + + return $fallback; + } + + /** + * Gets the asset version. + * + * @param array $entry Asset entry. + * @param string $path Absolute asset path. + * @return string|null Version. + */ + public static function entry_version( array $entry, string $path ): ?string { + foreach ( array( 'version', 'hash' ) as $key ) { + if ( isset( $entry[ $key ] ) && is_scalar( $entry[ $key ] ) && '' !== trim( (string) $entry[ $key ] ) ) { + return (string) $entry[ $key ]; + } + } + + $modified = filemtime( $path ); + + return false === $modified ? null : (string) $modified; + } + + /** + * Gets normalized dependency handles. + * + * @param array $entry Asset entry. + * @return array Dependency handles. + */ + public static function entry_dependencies( array $entry ): array { + $dependencies = $entry['dependencies'] ?? ( $entry['deps'] ?? array() ); + + if ( ! is_array( $dependencies ) ) { + return array(); + } + + return array_values( + array_filter( + array_map( + static function ( $dependency ) { + return is_scalar( $dependency ) ? trim( (string) $dependency ) : null; + }, + $dependencies + ), + static function ( $dependency ): bool { + return is_string( $dependency ) && '' !== $dependency; + } + ) + ); + } + + /** + * Gets an optional script module flag. + * + * @param array $entry Asset entry. + * @return bool|null Module flag, or null to use service default. + */ + public static function entry_module( array $entry ): ?bool { + if ( array_key_exists( 'module', $entry ) ) { + return ! empty( $entry['module'] ); + } + + if ( array_key_exists( 'type', $entry ) && is_scalar( $entry['type'] ) ) { + return 'module' === strtolower( trim( (string) $entry['type'] ) ); + } + + return null; + } + + /** + * Normalizes a relative asset path. + * + * @param string $path Candidate path. + * @return string Safe relative path. + */ + public static function normalize_relative_path( string $path ): string { + $path = trim( str_replace( '\\', '/', $path ) ); + + if ( + '' === $path + || false !== strpos( $path, "\0" ) + || 0 === strpos( $path, '/' ) + || preg_match( '#(^|/)\.\.(/|$)#', $path ) + ) { + return ''; + } + + while ( 0 === strpos( $path, './' ) ) { + $path = substr( $path, 2 ); + } + + return $path; + } + + /** + * Gets an empty scoped asset record set. + * + * @return array Empty records. + */ + public static function empty_context_records(): array { + return array( + 'frontend' => array( + 'css' => array(), + 'js' => array(), + ), + 'editor' => array( + 'css' => array(), + 'js' => array(), + ), + ); + } + + /** + * Normalizes scoped asset record groups. + * + * @param array $records Candidate records. + * @return array Normalized records. + */ + public static function normalize_context_records( array $records ): array { + $normalized = self::empty_context_records(); + + foreach ( array( 'frontend', 'editor' ) as $context ) { + if ( empty( $records[ $context ] ) || ! is_array( $records[ $context ] ) ) { + continue; + } + + foreach ( array( 'css', 'js' ) as $type ) { + $normalized[ $context ][ $type ] = isset( $records[ $context ][ $type ] ) && is_array( $records[ $context ][ $type ] ) + ? array_values( $records[ $context ][ $type ] ) + : array(); + } + } + + return $normalized; + } + + /** + * Merges two context asset record sets. + * + * @param array $base Base records. + * @param array $add Records to add. + * @return array Merged records. + */ + public static function merge_context_records( array $base, array $add ): array { + foreach ( array( 'frontend', 'editor' ) as $context ) { + foreach ( array( 'css', 'js' ) as $type ) { + $base[ $context ][ $type ] = array_merge( + $base[ $context ][ $type ] ?? array(), + $add[ $context ][ $type ] ?? array() + ); + } + } + + return $base; + } + + /** + * Sorts context asset records. + * + * @param array $records Context records. + * @return array Sorted context records. + */ + public static function sort_context_records( array $records ): array { + foreach ( array( 'frontend', 'editor' ) as $context ) { + foreach ( array( 'css', 'js' ) as $type ) { + $records[ $context ][ $type ] = FileDiscovery::sort_by_priority_and_relative( $records[ $context ][ $type ] ?? array() ); + } + } + + return $records; + } + + /** + * Checks whether any scoped asset records are present. + * + * @param array $records Scoped records. + * @return bool TRUE when records are present. + */ + public static function has_context_records( array $records ): bool { + foreach ( array( 'frontend', 'editor' ) as $context ) { + foreach ( array( 'css', 'js' ) as $type ) { + if ( ! empty( $records[ $context ][ $type ] ) ) { + return true; + } + } + } + + return false; + } +} diff --git a/includes/Support/AttributeBag.php b/includes/Support/AttributeBag.php new file mode 100644 index 0000000..68b0ead --- /dev/null +++ b/includes/Support/AttributeBag.php @@ -0,0 +1,392 @@ +<?php +/** + * Safe HTML attribute collection. + * + * @package Emulsify + */ + +namespace Emulsify\Theme\Support; + +/** + * Small AttributeBag implementation for Twig helper output. + */ +final class AttributeBag implements \Stringable { + + /** + * Attribute values keyed by attribute name. + * + * @var array<string, mixed> + */ + private $attributes = array(); + + /** + * Constructor. + * + * @param mixed $attributes Initial attributes. + */ + public function __construct( $attributes = array() ) { + $this->merge( $attributes ); + } + + /** + * Creates a cloned attribute bag. + * + * @return self Cloned attribute bag. + */ + public function copy(): self { + return new self( $this->toArray() ); + } + + /** + * Appends class tokens. + * + * @param mixed $value Class value. + * @return self Current instance. + */ + public function addClass( $value ): self { + $tokens = self::classTokensFromValue( $value ); + + if ( empty( $tokens ) ) { + return $this; + } + + $existing = $this->attributes['class'] ?? array(); + $this->attributes['class'] = self::uniqueList( array_merge( (array) $existing, $tokens ) ); + + return $this; + } + + /** + * Snake-case alias for PHP callers. + * + * @param mixed $value Class value. + * @return self Current instance. + */ + public function add_class( $value ): self { + return $this->addClass( $value ); + } + + /** + * Gets the normalized class list. + * + * @return array Class tokens. + */ + public function getClassList(): array { + return $this->attributes['class'] ?? array(); + } + + /** + * Sets or merges one attribute. + * + * @param string $name Attribute name. + * @param mixed $value Attribute value. + * @return self Current instance. + */ + public function set( string $name, $value ): self { + $attribute_name = trim( $name ); + + if ( ! self::isSafeAttributeName( $attribute_name ) ) { + return $this; + } + + if ( 'class' === $attribute_name ) { + $class_string = is_string( $value ) ? self::parseClassAttributeString( $value ) : null; + // Support legacy helper calls that pass class="foo bar" while storing + // classes internally as tokens for safe merging and deduplication. + $this->addClass( null !== $class_string ? $class_string : $value ); + return $this; + } + + $normalized_value = self::valueToAttributeParts( $value ); + + if ( null === $normalized_value ) { + return $this; + } + + $this->attributes[ $attribute_name ] = $normalized_value; + + return $this; + } + + /** + * Merges an array, traversable value, or another AttributeBag. + * + * @param mixed $value Attribute source. + * @return self Current instance. + */ + public function merge( $value ): self { + if ( empty( $value ) ) { + return $this; + } + + if ( $value instanceof self ) { + $value = $value->toArray(); + } + + if ( $value instanceof \Traversable ) { + $value = iterator_to_array( $value ); + } + + if ( is_object( $value ) ) { + $value = get_object_vars( $value ); + } + + if ( ! is_array( $value ) ) { + return $this; + } + + foreach ( $value as $name => $attribute_value ) { + if ( '_keys' === $name || ! is_string( $name ) ) { + continue; + } + + $this->set( $name, $attribute_value ); + } + + return $this; + } + + /** + * Converts attributes to a plain array. + * + * @return array<string, mixed> Attribute map. + */ + public function toArray(): array { + return $this->attributes; + } + + /** + * Core-compatible alias. + * + * @return array<string, mixed> Attribute map. + */ + public function toObject(): array { + return $this->toArray(); + } + + /** + * Serializes attributes for Twig output. + * + * @return string Safe serialized attributes. + */ + public function toString(): string { + $output = array(); + + foreach ( $this->attributes as $name => $value ) { + if ( 'class' === $name && is_array( $value ) ) { + if ( empty( $value ) ) { + continue; + } + + $output[] = sprintf( 'class="%s"', self::escapeAttributeValue( implode( ' ', $value ) ) ); + continue; + } + + if ( true === $value ) { + // Boolean HTML attributes such as "disabled" should render without a + // value. False/null were already filtered during normalization. + $output[] = $name; + continue; + } + + if ( is_array( $value ) ) { + $value = implode( ' ', self::flattenList( $value ) ); + } + + if ( is_scalar( $value ) ) { + $output[] = sprintf( '%s="%s"', $name, self::escapeAttributeValue( (string) $value ) ); + } + } + + return implode( ' ', $output ); + } + + /** + * Serializes attributes for string contexts. + * + * @return string Safe serialized attributes. + */ + public function __toString(): string { + return $this->toString(); + } + + /** + * Converts scalar, array, or AttributeBag values into class tokens. + * + * @param mixed $value Class value. + * @return array Class tokens. + */ + public static function classTokensFromValue( $value ): array { + if ( $value instanceof self ) { + return $value->getClassList(); + } + + $tokens = array(); + + foreach ( self::flattenList( $value ) as $item ) { + if ( $item instanceof self ) { + $tokens = array_merge( $tokens, $item->getClassList() ); + continue; + } + + foreach ( preg_split( '/\s+/', (string) $item ) as $candidate ) { + $token = self::cleanClassToken( $candidate ); + + if ( '' !== $token ) { + $tokens[] = $token; + } + } + } + + return self::uniqueList( $tokens ); + } + + /** + * Converts a value into serializable non-class attribute parts. + * + * @param mixed $value Attribute value. + * @return mixed Normalized value or null when it should not render. + */ + private static function valueToAttributeParts( $value ) { + if ( $value instanceof self ) { + return $value->toArray(); + } + + if ( null === $value || false === $value ) { + return null; + } + + if ( is_array( $value ) ) { + return array_map( + static function ( $item ): string { + return (string) $item; + }, + self::flattenList( $value ) + ); + } + + if ( true === $value ) { + return true; + } + + if ( is_scalar( $value ) ) { + return (string) $value; + } + + if ( is_object( $value ) && method_exists( $value, '__toString' ) ) { + return (string) $value; + } + + return null; + } + + /** + * Flattens scalar and nested array values. + * + * @param mixed $value Value to flatten. + * @return array Flattened list. + */ + private static function flattenList( $value ): array { + if ( null === $value || false === $value ) { + return array(); + } + + if ( ! is_array( $value ) ) { + return array( $value ); + } + + $items = array(); + + foreach ( $value as $item ) { + $items = array_merge( $items, self::flattenList( $item ) ); + } + + return $items; + } + + /** + * Removes duplicate values while preserving first-seen order. + * + * @param array $values Values to deduplicate. + * @return array Unique values. + */ + private static function uniqueList( array $values ): array { + $unique = array(); + $seen = array(); + + foreach ( $values as $value ) { + $key = (string) $value; + + if ( isset( $seen[ $key ] ) ) { + continue; + } + + $seen[ $key ] = true; + $unique[] = $value; + } + + return $unique; + } + + /** + * Cleans one value into a CSS class token. + * + * @param mixed $value Candidate class token. + * @return string Clean class token. + */ + private static function cleanClassToken( $value ): string { + $raw = trim( (string) $value ); + + if ( '' === $raw ) { + return ''; + } + + $cleaned = preg_replace( '/[^_a-zA-Z0-9-]+/', '-', $raw ); + $cleaned = trim( (string) $cleaned, '-' ); + $cleaned = preg_replace( '/^([0-9])/', '_$1', $cleaned ); + + return (string) $cleaned; + } + + /** + * Extracts a legacy class="..." string. + * + * @param string $value Candidate class attribute. + * @return string|null Class value when the string is class-only. + */ + private static function parseClassAttributeString( string $value ): ?string { + if ( preg_match( '/^class=(["\'])(.*?)\1$/', $value, $matches ) ) { + return $matches[2]; + } + + return null; + } + + /** + * Checks whether an attribute name can be serialized safely. + * + * @param string $name Attribute name. + * @return bool TRUE when safe. + */ + private static function isSafeAttributeName( string $name ): bool { + return 1 === preg_match( '/^[A-Za-z_:][A-Za-z0-9:_.-]*$/', $name ); + } + + /** + * Escapes an attribute value for double-quoted HTML output. + * + * @param string $value Attribute value. + * @return string Escaped value. + */ + private static function escapeAttributeValue( string $value ): string { + return strtr( + $value, + array( + '&' => '&', + '"' => '"', + '<' => '<', + '>' => '>', + ) + ); + } +} diff --git a/includes/Support/Diagnostics.php b/includes/Support/Diagnostics.php new file mode 100644 index 0000000..3c070ec --- /dev/null +++ b/includes/Support/Diagnostics.php @@ -0,0 +1,121 @@ +<?php +/** + * Shared runtime diagnostic helpers. + * + * @package Emulsify + */ + +namespace Emulsify\Theme\Support; + +/** + * Formats and reports duplicate discovery diagnostics. + */ +final class Diagnostics { + + /** + * Builds a skipped duplicate record. + * + * @param string $type Duplicate type. + * @param string $name Duplicate name. + * @param array $kept Higher-priority record. + * @param array $skipped Lower-priority skipped record. + * @param string $reason Human-readable reason. + * @return array Duplicate record. + */ + public static function duplicate_record( string $type, string $name, array $kept, array $skipped, string $reason ): array { + return array( + 'type' => $type, + 'name' => $name, + 'reason' => $reason, + 'kept' => $kept, + 'skipped' => $skipped, + ); + } + + /** + * Logs duplicate records and optionally exposes admin notices. + * + * @param array $duplicates Duplicate records. + * @param string $heading Admin notice heading. + * @return void + */ + public static function report_duplicates( array $duplicates, string $heading ): void { + if ( empty( $duplicates ) || ! defined( 'WP_DEBUG' ) || ! WP_DEBUG ) { + return; + } + + $messages = array(); + + foreach ( $duplicates as $duplicate ) { + if ( ! is_array( $duplicate ) ) { + continue; + } + + $messages[] = self::duplicate_message( $duplicate ); + error_log( '[Emulsify] ' . end( $messages ) ); + } + + self::admin_notice( $messages, $heading ); + } + + /** + * Adds an admin-only notice for skipped duplicates. + * + * @param array $messages Notice messages. + * @param string $heading Notice heading. + * @return void + */ + private static function admin_notice( array $messages, string $heading ): void { + if ( empty( $messages ) || ! function_exists( 'add_action' ) || ! function_exists( 'is_admin' ) || ! is_admin() ) { + return; + } + + add_action( + 'admin_notices', + static function () use ( $messages, $heading ): void { + if ( function_exists( 'current_user_can' ) && ! current_user_can( 'edit_theme_options' ) ) { + return; + } + + // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Heading is escaped by self::esc_html(). + echo '<div class="notice notice-warning"><p><strong>' . self::esc_html( $heading ) . '</strong></p><ul>'; + + foreach ( $messages as $message ) { + // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Message is escaped by self::esc_html(). + echo '<li>' . self::esc_html( $message ) . '</li>'; + } + + echo '</ul></div>'; + } + ); + } + + /** + * Formats a duplicate debug message. + * + * @param array $duplicate Duplicate record. + * @return string Debug message. + */ + private static function duplicate_message( array $duplicate ): string { + $kept = isset( $duplicate['kept']['metadata_path'] ) ? $duplicate['kept']['metadata_path'] : ( $duplicate['kept']['name'] ?? 'unknown' ); + $skipped = isset( $duplicate['skipped']['metadata_path'] ) ? $duplicate['skipped']['metadata_path'] : ( $duplicate['skipped']['name'] ?? 'unknown' ); + + return sprintf( + '%s "%s" skipped %s in favor of %s.', + isset( $duplicate['reason'] ) ? $duplicate['reason'] : 'Duplicate block definition.', + isset( $duplicate['name'] ) ? $duplicate['name'] : 'unknown', + $skipped, + $kept + ); + } + + /** + * Escapes HTML text. + * + * @param string $value Raw text. + * @return string Escaped text. + */ + private static function esc_html( string $value ): string { + return function_exists( 'esc_html' ) ? esc_html( $value ) : htmlspecialchars( $value, ENT_QUOTES, 'UTF-8' ); + } +} diff --git a/includes/Support/FileDiscovery.php b/includes/Support/FileDiscovery.php new file mode 100644 index 0000000..da2b4ef --- /dev/null +++ b/includes/Support/FileDiscovery.php @@ -0,0 +1,313 @@ +<?php +/** + * Shared filesystem discovery helpers. + * + * @package Emulsify + */ + +namespace Emulsify\Theme\Support; + +/** + * Normalizes theme roots and discovers files in deterministic order. + */ +final class FileDiscovery { + + /** + * Builds child-first theme root candidates for a theme-relative directory. + * + * @param string $directory Theme-relative directory. + * @param bool $include_uri Whether public URI values should be included. + * @return array Theme root candidates. + */ + public static function theme_roots( string $directory, bool $include_uri = false ): array { + $directory = trim( $directory, '/\\' ); + $roots = array(); + + if ( function_exists( 'get_stylesheet_directory' ) ) { + $root = array( + 'path' => rtrim( get_stylesheet_directory(), '/\\' ) . '/' . $directory, + 'priority' => 0, + 'source' => 'child', + ); + + if ( $include_uri && function_exists( 'get_stylesheet_directory_uri' ) ) { + $root['uri'] = rtrim( get_stylesheet_directory_uri(), '/' ) . '/' . $directory; + } + + $roots[] = $root; + } + + if ( function_exists( 'get_template_directory' ) ) { + $root = array( + 'path' => rtrim( get_template_directory(), '/\\' ) . '/' . $directory, + 'priority' => 1, + 'source' => 'parent', + ); + + if ( $include_uri && function_exists( 'get_template_directory_uri' ) ) { + $root['uri'] = rtrim( get_template_directory_uri(), '/' ) . '/' . $directory; + } + + $roots[] = $root; + } + + return $roots; + } + + /** + * Normalizes root records to readable, unique directories. + * + * @param array $roots Root records or path strings. + * @param array $options Normalization options. + * @return array Normalized root records. + */ + public static function normalize_roots( array $roots, array $options = array() ): array { + $require_uri = ! empty( $options['require_uri'] ); + $default_source = array_key_exists( 'default_source', $options ) ? $options['default_source'] : 'filtered'; + $default_source = null === $default_source || is_scalar( $default_source ) ? $default_source : 'filtered'; + $default_source = null === $default_source ? null : (string) $default_source; + $normalized = array(); + $seen = array(); + + foreach ( $roots as $index => $root ) { + $candidate = self::normalize_root_candidate( $root, (int) $index, $default_source, $require_uri ); + + if ( null === $candidate ) { + continue; + } + + $real = realpath( $candidate['path'] ); + + if ( + false === $real + || isset( $seen[ $real ] ) + || ! is_dir( $candidate['path'] ) + || ! is_readable( $candidate['path'] ) + ) { + continue; + } + + $seen[ $real ] = true; + $normalized[] = $candidate; + } + + return $normalized; + } + + /** + * Gets file records for normalized roots. + * + * @param array $roots Normalized root records. + * @param array $extensions Allowed extensions. Empty allows all files. + * @param bool $recursive Whether child directories should be scanned. + * @return array File records. + */ + public static function file_records( array $roots, array $extensions = array(), bool $recursive = true ): array { + $records = array(); + + foreach ( $roots as $root ) { + if ( empty( $root['path'] ) || ! is_scalar( $root['path'] ) ) { + continue; + } + + $path = rtrim( (string) $root['path'], '/\\' ); + $files = $recursive ? self::recursive_files( $path, $extensions ) : self::directory_files( $path, $extensions ); + + foreach ( $files as $file ) { + $record = array( + 'path' => $file, + 'priority' => isset( $root['priority'] ) ? (int) $root['priority'] : 0, + 'relative' => self::relative_path( $path, $file ), + 'root_path' => $path, + 'root_source' => isset( $root['source'] ) ? (string) $root['source'] : '', + ); + + if ( isset( $root['uri'] ) ) { + $record['root_uri'] = rtrim( (string) $root['uri'], '/' ); + } + + $records[] = $record; + } + } + + return $records; + } + + /** + * Gets all files below a directory in deterministic order. + * + * @param string $directory Directory to scan. + * @param array $extensions Allowed extensions. Empty allows all files. + * @return array Absolute file paths. + */ + public static function recursive_files( string $directory, array $extensions = array() ): array { + $files = array(); + $extensions = self::normalize_extensions( $extensions ); + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator( $directory, \RecursiveDirectoryIterator::SKIP_DOTS ) + ); + + foreach ( $iterator as $file ) { + if ( $file->isFile() && self::extension_allowed( $file, $extensions ) ) { + $files[] = $file->getPathname(); + } + } + + sort( $files ); + + return $files; + } + + /** + * Gets immediate child files from a directory in deterministic order. + * + * @param string $directory Directory to scan. + * @param array $extensions Allowed extensions. Empty allows all files. + * @return array Absolute file paths. + */ + public static function directory_files( string $directory, array $extensions = array() ): array { + $files = array(); + $extensions = self::normalize_extensions( $extensions ); + $iterator = new \FilesystemIterator( $directory, \FilesystemIterator::SKIP_DOTS ); + + foreach ( $iterator as $file ) { + if ( $file->isFile() && self::extension_allowed( $file, $extensions ) ) { + $files[] = $file->getPathname(); + } + } + + sort( $files ); + + return $files; + } + + /** + * Builds a POSIX relative path. + * + * @param string $base_path Base directory. + * @param string $path Absolute file path. + * @return string Relative path. + */ + public static function relative_path( string $base_path, string $path ): string { + $relative = ltrim( str_replace( rtrim( $base_path, '/\\' ), '', $path ), '/\\' ); + + return str_replace( '\\', '/', $relative ); + } + + /** + * Sorts records by root priority and relative path while preserving ties. + * + * @param array $records File records. + * @return array Sorted file records. + */ + public static function sort_by_priority_and_relative( array $records ): array { + $indexed = array(); + + foreach ( $records as $index => $record ) { + $indexed[] = array( + 'index' => (int) $index, + 'record' => $record, + ); + } + + usort( + $indexed, + static function ( array $left, array $right ): int { + $left_record = $left['record']; + $right_record = $right['record']; + $priority = ( (int) ( $left_record['priority'] ?? 0 ) ) <=> ( (int) ( $right_record['priority'] ?? 0 ) ); + + if ( 0 !== $priority ) { + return $priority; + } + + $relative = strcmp( (string) ( $left_record['relative'] ?? '' ), (string) ( $right_record['relative'] ?? '' ) ); + + return 0 === $relative ? $left['index'] <=> $right['index'] : $relative; + } + ); + + return array_column( $indexed, 'record' ); + } + + /** + * Normalizes a single root candidate. + * + * @param mixed $root Root record or path string. + * @param int $index Root list index. + * @param string|null $default_source Source value for string roots. + * @param bool $require_uri Whether roots must include a URI. + * @return array|null Normalized candidate, or null when invalid. + */ + private static function normalize_root_candidate( $root, int $index, ?string $default_source, bool $require_uri ): ?array { + if ( is_string( $root ) ) { + $root = array( + 'path' => $root, + 'source' => $default_source ?? (string) $index, + ); + } + + if ( ! is_array( $root ) || empty( $root['path'] ) || ! is_scalar( $root['path'] ) ) { + return null; + } + + $path = rtrim( (string) $root['path'], '/\\' ); + + if ( '' === $path ) { + return null; + } + + $candidate = array( + 'path' => $path, + 'priority' => isset( $root['priority'] ) ? (int) $root['priority'] : $index, + 'source' => isset( $root['source'] ) && is_scalar( $root['source'] ) + ? (string) $root['source'] + : ( $default_source ?? (string) $index ), + ); + + if ( isset( $root['uri'] ) && is_scalar( $root['uri'] ) ) { + $candidate['uri'] = rtrim( (string) $root['uri'], '/' ); + } + + if ( $require_uri && empty( $candidate['uri'] ) ) { + return null; + } + + return $candidate; + } + + /** + * Normalizes extension names for comparisons. + * + * @param array $extensions Extension list. + * @return array Normalized extension list. + */ + private static function normalize_extensions( array $extensions ): array { + $normalized = array(); + + foreach ( $extensions as $extension ) { + if ( ! is_scalar( $extension ) ) { + continue; + } + + $extension = strtolower( ltrim( trim( (string) $extension ), '.' ) ); + + if ( '' !== $extension ) { + $normalized[] = $extension; + } + } + + return array_values( array_unique( $normalized ) ); + } + + /** + * Checks whether a file extension is allowed. + * + * @param \SplFileInfo $file File info. + * @param array $extensions Normalized allowed extensions. + * @return bool TRUE when the file should be included. + */ + private static function extension_allowed( \SplFileInfo $file, array $extensions ): bool { + return empty( $extensions ) || in_array( strtolower( $file->getExtension() ), $extensions, true ); + } +} diff --git a/includes/Support/ProjectConfig.php b/includes/Support/ProjectConfig.php new file mode 100644 index 0000000..83b8c8c --- /dev/null +++ b/includes/Support/ProjectConfig.php @@ -0,0 +1,96 @@ +<?php +/** + * Reads Emulsify project configuration from the active child theme. + * + * @package Emulsify + */ + +namespace Emulsify\Theme\Support; + +/** + * Provides project.emulsify.json helpers. + */ +final class ProjectConfig { + + /** + * Reads active child theme Emulsify project metadata. + * + * @return array Project config, or an empty array when unavailable. + */ + public static function read(): array { + $path = get_stylesheet_directory() . '/project.emulsify.json'; + + if ( ! is_readable( $path ) ) { + return array(); + } + + $contents = file_get_contents( $path ); + + if ( false === $contents ) { + return array(); + } + + $data = json_decode( $contents, true ); + + return is_array( $data ) ? $data : array(); + } + + /** + * Reads the active child theme project machine name. + * + * @return string Project machine name, or empty string when unavailable. + */ + public static function machine_name(): string { + $data = self::read(); + + if ( empty( $data['project']['machineName'] ) ) { + return ''; + } + + $machine_name = trim( (string) $data['project']['machineName'] ); + + if ( 1 !== preg_match( '/^[A-Za-z0-9_-]+$/', $machine_name ) ) { + return ''; + } + + return $machine_name; + } + + /** + * Gets configured project structure implementation records. + * + * @return array Structure implementation records. + */ + public static function structure_implementations(): array { + $config = self::read(); + + return ! empty( $config['variant']['structureImplementations'] ) && is_array( $config['variant']['structureImplementations'] ) + ? $config['variant']['structureImplementations'] + : array(); + } + + /** + * Resolves a project config path to a safe theme-relative path. + * + * @param string $theme_dir Theme root. + * @param string $path Project-configured path. + * @return string Resolved path, or empty string when invalid. + */ + public static function resolve_theme_relative_path( string $theme_dir, string $path ): string { + $path = trim( str_replace( '\\', '/', $path ) ); + + if ( + '' === $path + || false !== strpos( $path, "\0" ) + || 0 === strpos( $path, '/' ) + || preg_match( '#(^|/)\.\.(/|$)#', $path ) + ) { + // project.emulsify.json paths are child-theme relative. Reject + // absolute paths and traversal so configuration cannot expose + // arbitrary server files as Twig namespaces. + return ''; + } + + return rtrim( $theme_dir, '/\\' ) . '/' . ltrim( $path, '/' ); + } +} diff --git a/includes/Twig/ProjectComponentLoader.php b/includes/Twig/ProjectComponentLoader.php new file mode 100644 index 0000000..8b89d40 --- /dev/null +++ b/includes/Twig/ProjectComponentLoader.php @@ -0,0 +1,286 @@ +<?php +/** + * Resolves project machine-name component references. + * + * @package Emulsify + */ + +namespace Emulsify\Theme\Twig; + +/** + * Wraps a Twig loader with project machine-name component support. + */ +final class ProjectComponentLoader implements \Twig\Loader\LoaderInterface { + + /** + * Project template prefix. + * + * @var string + */ + private $prefix; + + /** + * Component root paths. + * + * @var array + */ + private $roots; + + /** + * Wrapped Twig loader. + * + * @var \Twig\Loader\LoaderInterface + */ + private $loader; + + /** + * Resolved project template cache. + * + * @var array + */ + private $cache = array(); + + /** + * Constructs the project component loader wrapper. + * + * @param string $machine_name Active project machine name. + * @param array $roots Component root paths. + * @param \Twig\Loader\LoaderInterface $loader Wrapped Twig loader. + */ + public function __construct( string $machine_name, array $roots, \Twig\Loader\LoaderInterface $loader ) { + $this->prefix = $machine_name . ':'; + $this->roots = $roots; + $this->loader = $loader; + } + + /** + * Proxies filesystem namespace registration to the wrapped loader. + * + * @param string $path Filesystem path. + * @param string $namespace Twig namespace. + * @return void + */ + public function addPath( string $path, string $namespace = '__main__' ): void { + if ( method_exists( $this->loader, 'addPath' ) ) { + $this->loader->addPath( $path, $namespace ); + } + } + + /** + * Proxies prepended filesystem namespace registration when available. + * + * @param string $path Filesystem path. + * @param string $namespace Twig namespace. + * @return void + */ + public function prependPath( string $path, string $namespace = '__main__' ): void { + if ( method_exists( $this->loader, 'prependPath' ) ) { + $this->loader->prependPath( $path, $namespace ); + } + } + + /** + * Proxies filesystem namespace paths when available. + * + * @param string $namespace Twig namespace. + * @return array Namespace paths. + */ + public function getPaths( string $namespace = '__main__' ): array { + if ( method_exists( $this->loader, 'getPaths' ) ) { + return $this->loader->getPaths( $namespace ); + } + + return array(); + } + + /** + * Proxies filesystem namespace names when available. + * + * @return array Namespace names. + */ + public function getNamespaces(): array { + if ( method_exists( $this->loader, 'getNamespaces' ) ) { + return $this->loader->getNamespaces(); + } + + return array(); + } + + /** + * Returns source for a template logical name. + * + * @param string $name Template logical name. + * @return \Twig\Source Template source. + * @throws \Twig\Error\LoaderError When project template resolution fails. + */ + public function getSourceContext( string $name ): \Twig\Source { + $path = $this->find_project_template( $name ); + + if ( null !== $path ) { + $contents = file_get_contents( $path ); + + if ( false === $contents ) { + // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are not rendered HTML. + throw new \Twig\Error\LoaderError( sprintf( 'Unable to read project component template "%s".', $name ) ); + } + + return new \Twig\Source( $contents, $name, $path ); + } + + if ( $this->is_project_reference( $name ) ) { + // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are not rendered HTML. + throw new \Twig\Error\LoaderError( $this->project_loader_error( $name ) ); + } + + return $this->loader->getSourceContext( $name ); + } + + /** + * Gets the cache key for a template name. + * + * @param string $name Template logical name. + * @return string Cache key. + * @throws \Twig\Error\LoaderError When project template resolution fails. + */ + public function getCacheKey( string $name ): string { + $path = $this->find_project_template( $name ); + + if ( null !== $path ) { + return $path; + } + + if ( $this->is_project_reference( $name ) ) { + // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are not rendered HTML. + throw new \Twig\Error\LoaderError( $this->project_loader_error( $name ) ); + } + + return $this->loader->getCacheKey( $name ); + } + + /** + * Checks whether a template is fresh. + * + * @param string $name Template logical name. + * @param int $time Cached template timestamp. + * @return bool TRUE when the source is fresh. + * @throws \Twig\Error\LoaderError When project template resolution fails. + */ + public function isFresh( string $name, int $time ): bool { + $path = $this->find_project_template( $name ); + + if ( null !== $path ) { + $modified = filemtime( $path ); + + return false !== $modified && $modified <= $time; + } + + if ( $this->is_project_reference( $name ) ) { + // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are not rendered HTML. + throw new \Twig\Error\LoaderError( $this->project_loader_error( $name ) ); + } + + return $this->loader->isFresh( $name, $time ); + } + + /** + * Checks whether a template exists. + * + * @param string $name Template logical name. + * @return bool TRUE when the template exists. + */ + public function exists( string $name ) { + if ( $this->is_project_reference( $name ) ) { + return null !== $this->find_project_template( $name ); + } + + return $this->loader->exists( $name ); + } + + /** + * Finds a project component template path. + * + * @param string $name Template logical name. + * @return string|null Resolved template path. + */ + private function find_project_template( string $name ): ?string { + if ( array_key_exists( $name, $this->cache ) ) { + return $this->cache[ $name ]; + } + + $component = $this->project_component_name( $name ); + + if ( null === $component ) { + return null; + } + + $parts = explode( '/', $component ); + $filename = end( $parts ) . '.twig'; + + foreach ( $this->roots as $root ) { + // Support the single-directory component convention first, then the + // older flat component.twig form. Roots are already child-first. + foreach ( array( $component . '/' . $filename, $component . '.twig' ) as $relative ) { + $path = $root . '/' . $relative; + + if ( is_file( $path ) && is_readable( $path ) ) { + $realpath = realpath( $path ); + + $this->cache[ $name ] = false !== $realpath ? $realpath : $path; + + return $this->cache[ $name ]; + } + } + } + + return null; + } + + /** + * Gets the safe project component name from a template reference. + * + * @param string $name Template logical name. + * @return string|null Component name. + */ + private function project_component_name( string $name ): ?string { + if ( ! $this->is_project_reference( $name ) ) { + return null; + } + + $component = substr( $name, strlen( $this->prefix ) ); + + if ( 1 !== preg_match( '/^[A-Za-z0-9_-]+(?:\/[A-Za-z0-9_-]+)?$/', $component ) ) { + // Keep project component references intentionally shallow: + // component-name or group/component-name. Legacy deep paths are + // still available through explicit @namespace configuration. + return null; + } + + return $component; + } + + /** + * Checks whether a template name starts with the project prefix. + * + * @param string $name Template logical name. + * @return bool TRUE when the name targets this project namespace. + */ + private function is_project_reference( string $name ): bool { + return 0 === strncmp( $name, $this->prefix, strlen( $this->prefix ) ); + } + + /** + * Gets the loader error for a project reference. + * + * @param string $name Template logical name. + * @return string Error message. + */ + private function project_loader_error( string $name ): string { + $component = substr( $name, strlen( $this->prefix ) ); + + if ( '' === $component || false !== strpos( $component, "\0" ) || null === $this->project_component_name( $name ) ) { + return sprintf( 'Project component template name "%s" is invalid.', $name ); + } + + return sprintf( 'Project component template "%s" is not defined.', $name ); + } +} diff --git a/includes/Twig/SwitchExtension.php b/includes/Twig/SwitchExtension.php new file mode 100644 index 0000000..be64825 --- /dev/null +++ b/includes/Twig/SwitchExtension.php @@ -0,0 +1,27 @@ +<?php +/** + * Registers Emulsify switch tags with Twig. + * + * @package Emulsify + */ + +namespace Emulsify\Theme\Twig; + +use Twig\Extension\AbstractExtension; + +/** + * Adds the switch token parser to Twig. + */ +final class SwitchExtension extends AbstractExtension { + + /** + * Gets the extension token parsers. + * + * @return array Twig token parsers. + */ + public function getTokenParsers(): array { + return array( + new SwitchTokenParser(), + ); + } +} diff --git a/includes/Twig/SwitchNode.php b/includes/Twig/SwitchNode.php new file mode 100644 index 0000000..9a99b83 --- /dev/null +++ b/includes/Twig/SwitchNode.php @@ -0,0 +1,68 @@ +<?php +/** + * Compiles Emulsify switch tags. + * + * @package Emulsify + */ + +namespace Emulsify\Theme\Twig; + +use Twig\Compiler; +use Twig\Node\Node; + +/** + * Compiles switch nodes to native PHP switch statements. + */ +#[\Twig\Attribute\YieldReady] +final class SwitchNode extends Node { + + /** + * Compiles the switch and its branches. + * + * @param Compiler $compiler Twig compiler. + * @return void + */ + public function compile( Compiler $compiler ): void { + $compiler + ->addDebugInfo( $this ) + ->write( 'switch (' ) + ->subcompile( $this->getNode( 'value' ) ) + ->raw( ") {\n" ) + ->indent(); + + foreach ( $this->getNode( 'cases' ) as $case ) { + if ( ! $case->hasNode( 'body' ) ) { + continue; + } + + foreach ( $case->getNode( 'values' ) as $value ) { + $compiler + ->write( 'case ' ) + ->subcompile( $value ) + ->raw( ":\n" ); + } + + $compiler + ->write( "{\n" ) + ->indent() + ->subcompile( $case->getNode( 'body' ) ) + ->write( "break;\n" ) + ->outdent() + ->write( "}\n" ); + } + + if ( $this->hasNode( 'default' ) ) { + $compiler + ->write( "default:\n" ) + ->write( "{\n" ) + ->indent() + ->subcompile( $this->getNode( 'default' ) ) + ->outdent() + ->write( "}\n" ); + } + + $compiler + ->outdent() + ->write( "}\n" ); + } +} diff --git a/includes/Twig/SwitchTokenParser.php b/includes/Twig/SwitchTokenParser.php new file mode 100644 index 0000000..6e20ce3 --- /dev/null +++ b/includes/Twig/SwitchTokenParser.php @@ -0,0 +1,168 @@ +<?php +/** + * Parses Emulsify switch tags. + * + * @package Emulsify + */ + +namespace Emulsify\Theme\Twig; + +use Twig\Environment; +use Twig\Error\SyntaxError; +use Twig\Node\Node; +use Twig\Node\Nodes; +use Twig\Parser; +use Twig\Token; +use Twig\TokenParser\AbstractTokenParser; + +/** + * Parses switch, case, default, and endswitch tags. + * + * Based on the Emulsify Tools implementation for Drupal. + */ +final class SwitchTokenParser extends AbstractTokenParser { + + /** + * Stops case-value parsing before Twig consumes the "or" delimiter. + */ + private const CASE_VALUE_PRECEDENCE = 11; + + /** + * Gets the opening tag name. + * + * @return string Twig tag name. + */ + public function getTag(): string { + return 'switch'; + } + + /** + * Parses a switch block. + * + * @param Token $token Opening switch token. + * @return SwitchNode Parsed switch node. + * @throws SyntaxError When the switch block contains an unexpected tag. + */ + public function parse( Token $token ): SwitchNode { + $lineno = $token->getLine(); + $parser = $this->parser; + $stream = $parser->getStream(); + $nodes = array( + 'value' => $this->parse_expression( $parser ), + ); + + $stream->expect( Token::BLOCK_END_TYPE ); + + while ( $stream->getCurrent()->test( Token::TEXT_TYPE ) && '' === trim( $stream->getCurrent()->getValue() ) ) { + $stream->next(); + } + + $stream->expect( Token::BLOCK_START_TYPE ); + + $cases = array(); + $end = false; + + while ( ! $end ) { + $next = $stream->next(); + + switch ( $next->getValue() ) { + case 'case': + $values = array(); + + while ( true ) { + $values[] = $this->parse_expression( $parser, self::CASE_VALUE_PRECEDENCE ); + + if ( $stream->test( Token::OPERATOR_TYPE, 'or' ) ) { + $stream->next(); + continue; + } + + break; + } + + $stream->expect( Token::BLOCK_END_TYPE ); + $cases[] = $this->node_collection( + array( + 'values' => $this->node_collection( $values ), + 'body' => $parser->subparse( array( $this, 'decide_if_fork' ) ), + ) + ); + break; + + case 'default': + $stream->expect( Token::BLOCK_END_TYPE ); + $nodes['default'] = $parser->subparse( array( $this, 'decide_if_end' ) ); + break; + + case 'endswitch': + $end = true; + break; + + default: + $message = sprintf( + 'Unexpected tag "%s". Twig was looking for "case", "default", or "endswitch" to close the "switch" block started on line %d.', + $next->getValue(), + $lineno + ); + // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Twig syntax errors are not rendered HTML. + throw new SyntaxError( $message, $lineno ); + } + } + + $nodes['cases'] = $this->node_collection( $cases ); + $stream->expect( Token::BLOCK_END_TYPE ); + + return new SwitchNode( $nodes, array(), $lineno ); + } + + /** + * Checks whether the parser reached another switch branch. + * + * @param Token $token Current Twig token. + * @return bool TRUE at another switch branch. + */ + public function decide_if_fork( Token $token ): bool { + return $token->test( array( 'case', 'default', 'endswitch' ) ); + } + + /** + * Checks whether the parser reached the end of a switch block. + * + * @param Token $token Current Twig token. + * @return bool TRUE at the closing switch tag. + */ + public function decide_if_end( Token $token ): bool { + return $token->test( array( 'endswitch' ) ); + } + + /** + * Parses an expression across the Twig versions allowed by Timber 2.x. + * + * @param Parser $parser Active Twig parser. + * @param int $precedence Expression precedence floor. + * @return Node Parsed expression node. + */ + private function parse_expression( Parser $parser, int $precedence = 0 ): Node { + if ( version_compare( Environment::VERSION, '3.21.0', '>=' ) ) { + return $parser->parseExpression( $precedence ); + } + + $expression_parser = $parser->getExpressionParser(); + + return $expression_parser->parseExpression( $precedence ); + } + + /** + * Builds a node collection across the Twig versions allowed by Timber 2.x. + * + * @param array $nodes Child nodes. + * @return Node Twig node collection. + */ + private function node_collection( array $nodes ): Node { + if ( class_exists( Nodes::class ) ) { + return new Nodes( $nodes ); + } + + return new Node( $nodes ); + } +} diff --git a/index.php b/index.php old mode 100755 new mode 100644 index 804e555..6391409 --- a/index.php +++ b/index.php @@ -1,23 +1,31 @@ <?php /** * The main template file + * + * @package Emulsify + * * This is the most generic template file in a WordPress theme * and one of the two required files for a theme (the other being style.css). * It is used to display a page when nothing more specific matches a query. - * E.g., it puts together the home page when no home.php file exists - * - * Methods for TimberHelper can be found in the /lib sub-directory + * E.g., it puts together the home page when no home.php file exists. * - * @package WordPress - * @subpackage Timber - * @since Timber 0.1 + * @link https://developer.wordpress.org/themes/basics/template-hierarchy/ */ -$context = Timber::context(); -$context['posts'] = new Timber\PostQuery(); -$context['foo'] = 'bar'; -$templates = array( 'index.twig' ); +use Timber\Timber; + +if ( ! defined( 'ABSPATH' ) ) { + exit; +} + +$templates = array( '@templates/index.twig' ); + if ( is_home() ) { - array_unshift( $templates, 'front-page.twig', 'home.twig' ); + // Prefer WordPress' blog/front-page Twig names before the generic index + // fallback, matching the PHP template hierarchy without duplicate files. + array_unshift( $templates, '@templates/front-page.twig', '@templates/home.twig' ); } + +$context = Timber::context(); + Timber::render( $templates, $context ); diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..a2faa80 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,7356 @@ +{ + "name": "emulsify-wordpress", + "version": "2.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "emulsify-wordpress", + "version": "2.0.0", + "license": "GPL-2.0-only", + "devDependencies": { + "@commitlint/cli": "^19.4.1", + "@commitlint/config-conventional": "^19.4.1", + "@semantic-release/commit-analyzer": "^13.0.1", + "@semantic-release/github": "^12.0.8", + "@semantic-release/release-notes-generator": "^14.1.1", + "husky": "^9.1.5", + "lint-staged": "^15.2.9", + "semantic-release": "^25.0.5" + }, + "engines": { + "node": ">=24.10" + } + }, + "node_modules/@actions/core": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@actions/core/-/core-3.0.1.tgz", + "integrity": "sha512-a6d/Nwahm9fliVGRhdhofo40HjHQasUPusmc7vBfyky+7Z+P2A1J68zyFVaNcEclc/Se+eO595oAr5nwEIoIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@actions/exec": "^3.0.0", + "@actions/http-client": "^4.0.0" + } + }, + "node_modules/@actions/exec": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@actions/exec/-/exec-3.0.0.tgz", + "integrity": "sha512-6xH/puSoNBXb72VPlZVm7vQ+svQpFyA96qdDBvhB8eNZOE8LtPf9L4oAsfzK/crCL8YZ+19fKYVnM63Sl+Xzlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@actions/io": "^3.0.2" + } + }, + "node_modules/@actions/http-client": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-4.0.1.tgz", + "integrity": "sha512-+Nvd1ImaOZBSoPbsUtEhv+1z99H12xzncCkz0a3RuehINE81FZSe2QTj3uvAPTcJX/SCzUQHQ0D1GrPMbrPitg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tunnel": "^0.0.6", + "undici": "^6.23.0" + } + }, + "node_modules/@actions/http-client/node_modules/undici": { + "version": "6.27.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.27.0.tgz", + "integrity": "sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, + "node_modules/@actions/io": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@actions/io/-/io-3.0.2.tgz", + "integrity": "sha512-nRBchcMM+QK1pdjO7/idu86rbJI5YHUKCvKs0KxnSYbVe3F51UfGxuZX4Qy/fWlp6l7gWFwIkrOzN+oUK03kfw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@colors/colors": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", + "integrity": "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.1.90" + } + }, + "node_modules/@commitlint/cli": { + "version": "19.8.1", + "resolved": "https://registry.npmjs.org/@commitlint/cli/-/cli-19.8.1.tgz", + "integrity": "sha512-LXUdNIkspyxrlV6VDHWBmCZRtkEVRpBKxi2Gtw3J54cGWhLCTouVD/Q6ZSaSvd2YaDObWK8mDjrz3TIKtaQMAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/format": "^19.8.1", + "@commitlint/lint": "^19.8.1", + "@commitlint/load": "^19.8.1", + "@commitlint/read": "^19.8.1", + "@commitlint/types": "^19.8.1", + "tinyexec": "^1.0.0", + "yargs": "^17.0.0" + }, + "bin": { + "commitlint": "cli.js" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/config-conventional": { + "version": "19.8.1", + "resolved": "https://registry.npmjs.org/@commitlint/config-conventional/-/config-conventional-19.8.1.tgz", + "integrity": "sha512-/AZHJL6F6B/G959CsMAzrPKKZjeEiAVifRyEwXxcT6qtqbPwGw+iQxmNS+Bu+i09OCtdNRW6pNpBvgPrtMr9EQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/types": "^19.8.1", + "conventional-changelog-conventionalcommits": "^7.0.2" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/config-validator": { + "version": "19.8.1", + "resolved": "https://registry.npmjs.org/@commitlint/config-validator/-/config-validator-19.8.1.tgz", + "integrity": "sha512-0jvJ4u+eqGPBIzzSdqKNX1rvdbSU1lPNYlfQQRIFnBgLy26BtC0cFnr7c/AyuzExMxWsMOte6MkTi9I3SQ3iGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/types": "^19.8.1", + "ajv": "^8.11.0" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/ensure": { + "version": "19.8.1", + "resolved": "https://registry.npmjs.org/@commitlint/ensure/-/ensure-19.8.1.tgz", + "integrity": "sha512-mXDnlJdvDzSObafjYrOSvZBwkD01cqB4gbnnFuVyNpGUM5ijwU/r/6uqUmBXAAOKRfyEjpkGVZxaDsCVnHAgyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/types": "^19.8.1", + "lodash.camelcase": "^4.3.0", + "lodash.kebabcase": "^4.1.1", + "lodash.snakecase": "^4.1.1", + "lodash.startcase": "^4.4.0", + "lodash.upperfirst": "^4.3.1" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/execute-rule": { + "version": "19.8.1", + "resolved": "https://registry.npmjs.org/@commitlint/execute-rule/-/execute-rule-19.8.1.tgz", + "integrity": "sha512-YfJyIqIKWI64Mgvn/sE7FXvVMQER/Cd+s3hZke6cI1xgNT/f6ZAz5heND0QtffH+KbcqAwXDEE1/5niYayYaQA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/format": { + "version": "19.8.1", + "resolved": "https://registry.npmjs.org/@commitlint/format/-/format-19.8.1.tgz", + "integrity": "sha512-kSJj34Rp10ItP+Eh9oCItiuN/HwGQMXBnIRk69jdOwEW9llW9FlyqcWYbHPSGofmjsqeoxa38UaEA5tsbm2JWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/types": "^19.8.1", + "chalk": "^5.3.0" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/is-ignored": { + "version": "19.8.1", + "resolved": "https://registry.npmjs.org/@commitlint/is-ignored/-/is-ignored-19.8.1.tgz", + "integrity": "sha512-AceOhEhekBUQ5dzrVhDDsbMaY5LqtN8s1mqSnT2Kz1ERvVZkNihrs3Sfk1Je/rxRNbXYFzKZSHaPsEJJDJV8dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/types": "^19.8.1", + "semver": "^7.6.0" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/lint": { + "version": "19.8.1", + "resolved": "https://registry.npmjs.org/@commitlint/lint/-/lint-19.8.1.tgz", + "integrity": "sha512-52PFbsl+1EvMuokZXLRlOsdcLHf10isTPlWwoY1FQIidTsTvjKXVXYb7AvtpWkDzRO2ZsqIgPK7bI98x8LRUEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/is-ignored": "^19.8.1", + "@commitlint/parse": "^19.8.1", + "@commitlint/rules": "^19.8.1", + "@commitlint/types": "^19.8.1" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/load": { + "version": "19.8.1", + "resolved": "https://registry.npmjs.org/@commitlint/load/-/load-19.8.1.tgz", + "integrity": "sha512-9V99EKG3u7z+FEoe4ikgq7YGRCSukAcvmKQuTtUyiYPnOd9a2/H9Ak1J9nJA1HChRQp9OA/sIKPugGS+FK/k1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/config-validator": "^19.8.1", + "@commitlint/execute-rule": "^19.8.1", + "@commitlint/resolve-extends": "^19.8.1", + "@commitlint/types": "^19.8.1", + "chalk": "^5.3.0", + "cosmiconfig": "^9.0.0", + "cosmiconfig-typescript-loader": "^6.1.0", + "lodash.isplainobject": "^4.0.6", + "lodash.merge": "^4.6.2", + "lodash.uniq": "^4.5.0" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/message": { + "version": "19.8.1", + "resolved": "https://registry.npmjs.org/@commitlint/message/-/message-19.8.1.tgz", + "integrity": "sha512-+PMLQvjRXiU+Ae0Wc+p99EoGEutzSXFVwQfa3jRNUZLNW5odZAyseb92OSBTKCu+9gGZiJASt76Cj3dLTtcTdg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/parse": { + "version": "19.8.1", + "resolved": "https://registry.npmjs.org/@commitlint/parse/-/parse-19.8.1.tgz", + "integrity": "sha512-mmAHYcMBmAgJDKWdkjIGq50X4yB0pSGpxyOODwYmoexxxiUCy5JJT99t1+PEMK7KtsCtzuWYIAXYAiKR+k+/Jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/types": "^19.8.1", + "conventional-changelog-angular": "^7.0.0", + "conventional-commits-parser": "^5.0.0" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/read": { + "version": "19.8.1", + "resolved": "https://registry.npmjs.org/@commitlint/read/-/read-19.8.1.tgz", + "integrity": "sha512-03Jbjb1MqluaVXKHKRuGhcKWtSgh3Jizqy2lJCRbRrnWpcM06MYm8th59Xcns8EqBYvo0Xqb+2DoZFlga97uXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/top-level": "^19.8.1", + "@commitlint/types": "^19.8.1", + "git-raw-commits": "^4.0.0", + "minimist": "^1.2.8", + "tinyexec": "^1.0.0" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/resolve-extends": { + "version": "19.8.1", + "resolved": "https://registry.npmjs.org/@commitlint/resolve-extends/-/resolve-extends-19.8.1.tgz", + "integrity": "sha512-GM0mAhFk49I+T/5UCYns5ayGStkTt4XFFrjjf0L4S26xoMTSkdCf9ZRO8en1kuopC4isDFuEm7ZOm/WRVeElVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/config-validator": "^19.8.1", + "@commitlint/types": "^19.8.1", + "global-directory": "^4.0.1", + "import-meta-resolve": "^4.0.0", + "lodash.mergewith": "^4.6.2", + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/rules": { + "version": "19.8.1", + "resolved": "https://registry.npmjs.org/@commitlint/rules/-/rules-19.8.1.tgz", + "integrity": "sha512-Hnlhd9DyvGiGwjfjfToMi1dsnw1EXKGJNLTcsuGORHz6SS9swRgkBsou33MQ2n51/boIDrbsg4tIBbRpEWK2kw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/ensure": "^19.8.1", + "@commitlint/message": "^19.8.1", + "@commitlint/to-lines": "^19.8.1", + "@commitlint/types": "^19.8.1" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/to-lines": { + "version": "19.8.1", + "resolved": "https://registry.npmjs.org/@commitlint/to-lines/-/to-lines-19.8.1.tgz", + "integrity": "sha512-98Mm5inzbWTKuZQr2aW4SReY6WUukdWXuZhrqf1QdKPZBCCsXuG87c+iP0bwtD6DBnmVVQjgp4whoHRVixyPBg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/top-level": { + "version": "19.8.1", + "resolved": "https://registry.npmjs.org/@commitlint/top-level/-/top-level-19.8.1.tgz", + "integrity": "sha512-Ph8IN1IOHPSDhURCSXBz44+CIu+60duFwRsg6HqaISFHQHbmBtxVw4ZrFNIYUzEP7WwrNPxa2/5qJ//NK1FGcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^7.0.0" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/types": { + "version": "19.8.1", + "resolved": "https://registry.npmjs.org/@commitlint/types/-/types-19.8.1.tgz", + "integrity": "sha512-/yCrWGCoA1SVKOks25EGadP9Pnj0oAIHGpl2wH2M2Y46dPM2ueb8wyCVOD7O3WCTkaJ0IkKvzhl1JY7+uCT2Dw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/conventional-commits-parser": "^5.0.0", + "chalk": "^5.3.0" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@octokit/auth-token": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-6.0.0.tgz", + "integrity": "sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/core": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/@octokit/core/-/core-7.0.6.tgz", + "integrity": "sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/auth-token": "^6.0.0", + "@octokit/graphql": "^9.0.3", + "@octokit/request": "^10.0.6", + "@octokit/request-error": "^7.0.2", + "@octokit/types": "^16.0.0", + "before-after-hook": "^4.0.0", + "universal-user-agent": "^7.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/endpoint": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-11.0.3.tgz", + "integrity": "sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^16.0.0", + "universal-user-agent": "^7.0.2" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/graphql": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-9.0.3.tgz", + "integrity": "sha512-grAEuupr/C1rALFnXTv6ZQhFuL1D8G5y8CN04RgrO4FIPMrtm+mcZzFG7dcBm+nq+1ppNixu+Jd78aeJOYxlGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/request": "^10.0.6", + "@octokit/types": "^16.0.0", + "universal-user-agent": "^7.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/openapi-types": { + "version": "27.0.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-27.0.0.tgz", + "integrity": "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@octokit/plugin-paginate-rest": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-14.0.0.tgz", + "integrity": "sha512-fNVRE7ufJiAA3XUrha2omTA39M6IXIc6GIZLvlbsm8QOQCYvpq/LkMNGyFlB1d8hTDzsAXa3OKtybdMAYsV/fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^16.0.0" + }, + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "@octokit/core": ">=6" + } + }, + "node_modules/@octokit/plugin-retry": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-retry/-/plugin-retry-8.1.0.tgz", + "integrity": "sha512-O1FZgXeiGb2sowEr/hYTr6YunGdSAFWnr2fyW39Ah85H8O33ELASQxcvOFF5LE6Tjekcyu2ms4qAzJVhSaJxTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/request-error": "^7.0.2", + "@octokit/types": "^16.0.0", + "bottleneck": "^2.15.3" + }, + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "@octokit/core": ">=7" + } + }, + "node_modules/@octokit/plugin-throttling": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/@octokit/plugin-throttling/-/plugin-throttling-11.0.3.tgz", + "integrity": "sha512-34eE0RkFCKycLl2D2kq7W+LovheM/ex3AwZCYN8udpi6bxsyjZidb2McXs69hZhLmJlDqTSP8cH+jSRpiaijBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^16.0.0", + "bottleneck": "^2.15.3" + }, + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "@octokit/core": "^7.0.0" + } + }, + "node_modules/@octokit/request": { + "version": "10.0.10", + "resolved": "https://registry.npmjs.org/@octokit/request/-/request-10.0.10.tgz", + "integrity": "sha512-KxNC2pTqqhszMNrf12ZRd4PonRgyJdsM4F/jySiddQK+DsRcfBtUvqn8t7UsyZhnRJHvX46OohDt5N3VqIWC2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/endpoint": "^11.0.3", + "@octokit/request-error": "^7.0.2", + "@octokit/types": "^16.0.0", + "content-type": "^2.0.0", + "json-with-bigint": "^3.5.3", + "universal-user-agent": "^7.0.2" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/request-error": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-7.1.0.tgz", + "integrity": "sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^16.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/types": { + "version": "16.0.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-16.0.0.tgz", + "integrity": "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^27.0.0" + } + }, + "node_modules/@pnpm/config.env-replace": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@pnpm/config.env-replace/-/config.env-replace-1.1.0.tgz", + "integrity": "sha512-htyl8TWnKL7K/ESFa1oW2UB5lVDxuF5DpM7tBi6Hu2LNL3mWkIzNLG6N4zoCUP1lCKNxWy/3iu8mS8MvToGd6w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.22.0" + } + }, + "node_modules/@pnpm/network.ca-file": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@pnpm/network.ca-file/-/network.ca-file-1.0.2.tgz", + "integrity": "sha512-YcPQ8a0jwYU9bTdJDpXjMi7Brhkr1mXsXrUJvjqM2mQDgkRiz8jFaQGOdaLxgjtUfQgZhKy/O3cG/YwmgKaxLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "4.2.10" + }, + "engines": { + "node": ">=12.22.0" + } + }, + "node_modules/@pnpm/network.ca-file/node_modules/graceful-fs": { + "version": "4.2.10", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.10.tgz", + "integrity": "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==", + "dev": true, + "license": "ISC" + }, + "node_modules/@pnpm/npm-conf": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@pnpm/npm-conf/-/npm-conf-3.0.3.tgz", + "integrity": "sha512-//0sR/cow/s4ICQaYoAobOl4aU8cjU6x/V24V7XkKotb9+O+3zySIYp146vpaobYHnxa4pZX8NkV54Z5AwbDKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@pnpm/config.env-replace": "^1.1.0", + "@pnpm/network.ca-file": "^1.0.1", + "config-chain": "^1.1.11" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@sec-ant/readable-stream": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz", + "integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@semantic-release/commit-analyzer": { + "version": "13.0.1", + "resolved": "https://registry.npmjs.org/@semantic-release/commit-analyzer/-/commit-analyzer-13.0.1.tgz", + "integrity": "sha512-wdnBPHKkr9HhNhXOhZD5a2LNl91+hs8CC2vsAVYxtZH3y0dV3wKn+uZSN61rdJQZ8EGxzWB3inWocBHV9+u/CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "conventional-changelog-angular": "^8.0.0", + "conventional-changelog-writer": "^8.0.0", + "conventional-commits-filter": "^5.0.0", + "conventional-commits-parser": "^6.0.0", + "debug": "^4.0.0", + "import-from-esm": "^2.0.0", + "lodash-es": "^4.17.21", + "micromatch": "^4.0.2" + }, + "engines": { + "node": ">=20.8.1" + }, + "peerDependencies": { + "semantic-release": ">=20.1.0" + } + }, + "node_modules/@semantic-release/commit-analyzer/node_modules/conventional-changelog-angular": { + "version": "8.3.1", + "resolved": "https://registry.npmjs.org/conventional-changelog-angular/-/conventional-changelog-angular-8.3.1.tgz", + "integrity": "sha512-6gfI3otXK5Ph5DfCOI1dblr+kN3FAm5a97hYoQkqNZxOaYa5WKfXH+AnpsmS+iUH2mgVC2Cg2Qw9m5OKcmNrIg==", + "dev": true, + "license": "ISC", + "dependencies": { + "compare-func": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@semantic-release/commit-analyzer/node_modules/conventional-commits-parser": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/conventional-commits-parser/-/conventional-commits-parser-6.4.0.tgz", + "integrity": "sha512-tvRg7FIBNlyPzjdG8wWRlPHQJJHI7DylhtRGeU9Lq+JuoPh5BKpPRX83ZdLrvXuOSu5Eo/e7SzOQhU4Hd2Miuw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@simple-libs/stream-utils": "^1.2.0", + "meow": "^13.0.0" + }, + "bin": { + "conventional-commits-parser": "dist/cli/index.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@semantic-release/github": { + "version": "12.0.8", + "resolved": "https://registry.npmjs.org/@semantic-release/github/-/github-12.0.8.tgz", + "integrity": "sha512-tej5AAgK5X9wHRoDmYhecMXEHEkFeGOY1XsEblKxu8pIQwahzf1STYyr7iPU6Lpbg6C5I3N2w/ocXrBo+L7jhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/core": "^7.0.0", + "@octokit/plugin-paginate-rest": "^14.0.0", + "@octokit/plugin-retry": "^8.0.0", + "@octokit/plugin-throttling": "^11.0.0", + "@semantic-release/error": "^4.0.0", + "aggregate-error": "^5.0.0", + "debug": "^4.3.4", + "dir-glob": "^3.0.1", + "http-proxy-agent": "^9.0.0", + "https-proxy-agent": "^9.0.0", + "issue-parser": "^7.0.0", + "lodash-es": "^4.17.21", + "mime": "^4.0.0", + "p-filter": "^4.0.0", + "tinyglobby": "^0.2.14", + "undici": "^7.0.0", + "url-join": "^5.0.0" + }, + "engines": { + "node": "^22.14.0 || >= 24.10.0" + }, + "peerDependencies": { + "semantic-release": ">=24.1.0" + } + }, + "node_modules/@semantic-release/github/node_modules/@semantic-release/error": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@semantic-release/error/-/error-4.0.0.tgz", + "integrity": "sha512-mgdxrHTLOjOddRVYIYDo0fR3/v61GNN1YGkfbrjuIKg/uMgCd+Qzo3UAXJ+woLQQpos4pl5Esuw5A7AoNlzjUQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@semantic-release/github/node_modules/aggregate-error": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-5.0.0.tgz", + "integrity": "sha512-gOsf2YwSlleG6IjRYG2A7k0HmBMEo6qVNk9Bp/EaLgAJT5ngH6PXbqa4ItvnEwCm/velL5jAnQgsHsWnjhGmvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "clean-stack": "^5.2.0", + "indent-string": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@semantic-release/github/node_modules/clean-stack": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-5.3.0.tgz", + "integrity": "sha512-9ngPTOhYGQqNVSfeJkYXHmF7AGWp4/nN5D/QqNQs3Dvxd1Kk/WpjHfNujKHYUQ/5CoGyOyFNoWSPk5afzP0QVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "5.0.0" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@semantic-release/github/node_modules/indent-string": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz", + "integrity": "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@semantic-release/npm": { + "version": "13.1.5", + "resolved": "https://registry.npmjs.org/@semantic-release/npm/-/npm-13.1.5.tgz", + "integrity": "sha512-Hq5UxzoatN3LHiq2rTsWS54nCdqJHlsssGERCo8WlvdfFA9LoN0vO+OuKVSjtNapIc/S8C2LBj206wKLHg62mg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@actions/core": "^3.0.0", + "@semantic-release/error": "^4.0.0", + "aggregate-error": "^5.0.0", + "env-ci": "^11.2.0", + "execa": "^9.0.0", + "fs-extra": "^11.0.0", + "lodash-es": "^4.17.21", + "nerf-dart": "^1.0.0", + "normalize-url": "^9.0.0", + "npm": "^11.6.2", + "rc": "^1.2.8", + "read-pkg": "^10.0.0", + "registry-auth-token": "^5.0.0", + "semver": "^7.1.2", + "tempy": "^3.0.0" + }, + "engines": { + "node": "^22.14.0 || >= 24.10.0" + }, + "peerDependencies": { + "semantic-release": ">=20.1.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/@semantic-release/error": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@semantic-release/error/-/error-4.0.0.tgz", + "integrity": "sha512-mgdxrHTLOjOddRVYIYDo0fR3/v61GNN1YGkfbrjuIKg/uMgCd+Qzo3UAXJ+woLQQpos4pl5Esuw5A7AoNlzjUQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@semantic-release/npm/node_modules/aggregate-error": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-5.0.0.tgz", + "integrity": "sha512-gOsf2YwSlleG6IjRYG2A7k0HmBMEo6qVNk9Bp/EaLgAJT5ngH6PXbqa4ItvnEwCm/velL5jAnQgsHsWnjhGmvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "clean-stack": "^5.2.0", + "indent-string": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@semantic-release/npm/node_modules/clean-stack": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-5.3.0.tgz", + "integrity": "sha512-9ngPTOhYGQqNVSfeJkYXHmF7AGWp4/nN5D/QqNQs3Dvxd1Kk/WpjHfNujKHYUQ/5CoGyOyFNoWSPk5afzP0QVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "5.0.0" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@semantic-release/npm/node_modules/execa": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-9.6.1.tgz", + "integrity": "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/merge-streams": "^4.0.0", + "cross-spawn": "^7.0.6", + "figures": "^6.1.0", + "get-stream": "^9.0.0", + "human-signals": "^8.0.1", + "is-plain-obj": "^4.1.0", + "is-stream": "^4.0.1", + "npm-run-path": "^6.0.0", + "pretty-ms": "^9.2.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^4.0.0", + "yoctocolors": "^2.1.1" + }, + "engines": { + "node": "^18.19.0 || >=20.5.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/@semantic-release/npm/node_modules/get-stream": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-9.0.1.tgz", + "integrity": "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sec-ant/readable-stream": "^0.4.1", + "is-stream": "^4.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@semantic-release/npm/node_modules/human-signals": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-8.0.1.tgz", + "integrity": "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/indent-string": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz", + "integrity": "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@semantic-release/npm/node_modules/is-stream": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", + "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm": { + "version": "11.18.0", + "resolved": "https://registry.npmjs.org/npm/-/npm-11.18.0.tgz", + "integrity": "sha512-T67M4L5wNm0cZ7EBLErcEkY1SmzEW/WJ+SADBzsFUY1UdAPfFHXFQtZ6SEXiK0+vzXysCvAsepbMaBTwnrAD+w==", + "bundleDependencies": [ + "@isaacs/string-locale-compare", + "@npmcli/arborist", + "@npmcli/config", + "@npmcli/fs", + "@npmcli/map-workspaces", + "@npmcli/metavuln-calculator", + "@npmcli/package-json", + "@npmcli/promise-spawn", + "@npmcli/redact", + "@npmcli/run-script", + "@sigstore/tuf", + "abbrev", + "archy", + "cacache", + "chalk", + "ci-info", + "fastest-levenshtein", + "fs-minipass", + "glob", + "graceful-fs", + "hosted-git-info", + "ini", + "init-package-json", + "is-cidr", + "json-parse-even-better-errors", + "libnpmaccess", + "libnpmdiff", + "libnpmexec", + "libnpmfund", + "libnpmorg", + "libnpmpack", + "libnpmpublish", + "libnpmsearch", + "libnpmteam", + "libnpmversion", + "make-fetch-happen", + "minimatch", + "minipass", + "minipass-pipeline", + "ms", + "node-gyp", + "nopt", + "npm-audit-report", + "npm-install-checks", + "npm-package-arg", + "npm-pick-manifest", + "npm-profile", + "npm-registry-fetch", + "npm-user-validate", + "p-map", + "pacote", + "parse-conflict-json", + "proc-log", + "qrcode-terminal", + "read", + "semver", + "spdx-expression-parse", + "ssri", + "supports-color", + "tar", + "text-table", + "tiny-relative-date", + "treeverse", + "validate-npm-package-name", + "which" + ], + "dev": true, + "license": "Artistic-2.0", + "workspaces": [ + "docs", + "smoke-tests", + "mock-globals", + "mock-registry", + "workspaces/*" + ], + "dependencies": { + "@isaacs/string-locale-compare": "^1.1.0", + "@npmcli/arborist": "^9.9.0", + "@npmcli/config": "^10.12.0", + "@npmcli/fs": "^5.0.0", + "@npmcli/map-workspaces": "^5.0.3", + "@npmcli/metavuln-calculator": "^9.0.3", + "@npmcli/package-json": "^7.0.5", + "@npmcli/promise-spawn": "^9.0.1", + "@npmcli/redact": "^4.0.0", + "@npmcli/run-script": "^10.0.4", + "@sigstore/tuf": "^4.0.2", + "abbrev": "^4.0.0", + "archy": "~1.0.0", + "cacache": "^20.0.4", + "chalk": "^5.6.2", + "ci-info": "^4.4.0", + "fastest-levenshtein": "^1.0.16", + "fs-minipass": "^3.0.3", + "glob": "^13.0.6", + "graceful-fs": "^4.2.11", + "hosted-git-info": "^9.0.3", + "ini": "^6.0.0", + "init-package-json": "^8.2.5", + "is-cidr": "^6.0.4", + "json-parse-even-better-errors": "^5.0.0", + "libnpmaccess": "^10.0.3", + "libnpmdiff": "^8.1.11", + "libnpmexec": "^10.3.1", + "libnpmfund": "^7.0.25", + "libnpmorg": "^8.0.1", + "libnpmpack": "^9.1.11", + "libnpmpublish": "^11.2.0", + "libnpmsearch": "^9.0.1", + "libnpmteam": "^8.0.2", + "libnpmversion": "^8.0.4", + "make-fetch-happen": "^15.0.6", + "minimatch": "^10.2.5", + "minipass": "^7.1.3", + "minipass-pipeline": "^1.2.4", + "ms": "^2.1.2", + "node-gyp": "^12.4.0", + "nopt": "^9.0.0", + "npm-audit-report": "^7.0.0", + "npm-install-checks": "^8.0.0", + "npm-package-arg": "^13.0.2", + "npm-pick-manifest": "^11.0.3", + "npm-profile": "^12.0.2", + "npm-registry-fetch": "^19.1.1", + "npm-user-validate": "^4.0.0", + "p-map": "^7.0.4", + "pacote": "^21.5.1", + "parse-conflict-json": "^5.0.1", + "proc-log": "^6.1.0", + "qrcode-terminal": "^0.12.0", + "read": "^5.0.1", + "semver": "^7.8.5", + "spdx-expression-parse": "^4.0.0", + "ssri": "^13.0.1", + "supports-color": "^10.2.2", + "tar": "^7.5.19", + "text-table": "~0.2.0", + "tiny-relative-date": "^2.0.2", + "treeverse": "^3.0.0", + "validate-npm-package-name": "^7.0.2", + "which": "^6.0.1" + }, + "bin": { + "npm": "bin/npm-cli.js", + "npx": "bin/npx-cli.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm-run-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz", + "integrity": "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^4.0.0", + "unicorn-magic": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/@gar/promise-retry": { + "version": "1.0.3", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/@isaacs/string-locale-compare": { + "version": "1.1.0", + "dev": true, + "inBundle": true, + "license": "ISC" + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/@npmcli/agent": { + "version": "4.0.2", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "agent-base": "^7.1.0", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.1", + "lru-cache": "^11.2.1", + "socks-proxy-agent": "^8.0.3" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/@npmcli/arborist": { + "version": "9.9.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@gar/promise-retry": "^1.0.0", + "@isaacs/string-locale-compare": "^1.1.0", + "@npmcli/fs": "^5.0.0", + "@npmcli/installed-package-contents": "^4.0.0", + "@npmcli/map-workspaces": "^5.0.0", + "@npmcli/metavuln-calculator": "^9.0.2", + "@npmcli/name-from-folder": "^4.0.0", + "@npmcli/node-gyp": "^5.0.0", + "@npmcli/package-json": "^7.0.0", + "@npmcli/query": "^5.0.0", + "@npmcli/redact": "^4.0.0", + "@npmcli/run-script": "^10.0.0", + "bin-links": "^6.0.0", + "cacache": "^20.0.1", + "common-ancestor-path": "^2.0.0", + "hosted-git-info": "^9.0.0", + "json-stringify-nice": "^1.1.4", + "lru-cache": "^11.2.1", + "minimatch": "^10.0.3", + "nopt": "^9.0.0", + "npm-install-checks": "^8.0.0", + "npm-package-arg": "^13.0.0", + "npm-pick-manifest": "^11.0.1", + "npm-registry-fetch": "^19.0.0", + "pacote": "^21.0.2", + "parse-conflict-json": "^5.0.1", + "proc-log": "^6.0.0", + "proggy": "^4.0.0", + "promise-all-reject-late": "^1.0.0", + "promise-call-limit": "^3.0.1", + "semver": "^7.3.7", + "ssri": "^13.0.0", + "treeverse": "^3.0.0", + "walk-up-path": "^4.0.0" + }, + "bin": { + "arborist": "bin/index.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/@npmcli/config": { + "version": "10.12.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/map-workspaces": "^5.0.0", + "@npmcli/package-json": "^7.0.0", + "ci-info": "^4.0.0", + "ini": "^6.0.0", + "nopt": "^9.0.0", + "proc-log": "^6.0.0", + "semver": "^7.3.5", + "walk-up-path": "^4.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/@npmcli/fs": { + "version": "5.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/@npmcli/git": { + "version": "7.0.2", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@gar/promise-retry": "^1.0.0", + "@npmcli/promise-spawn": "^9.0.0", + "ini": "^6.0.0", + "lru-cache": "^11.2.1", + "npm-pick-manifest": "^11.0.1", + "proc-log": "^6.0.0", + "semver": "^7.3.5", + "which": "^6.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/@npmcli/installed-package-contents": { + "version": "4.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "npm-bundled": "^5.0.0", + "npm-normalize-package-bin": "^5.0.0" + }, + "bin": { + "installed-package-contents": "bin/index.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/@npmcli/map-workspaces": { + "version": "5.0.3", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/name-from-folder": "^4.0.0", + "@npmcli/package-json": "^7.0.0", + "glob": "^13.0.0", + "minimatch": "^10.0.3" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/@npmcli/metavuln-calculator": { + "version": "9.0.3", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "cacache": "^20.0.0", + "json-parse-even-better-errors": "^5.0.0", + "pacote": "^21.0.0", + "proc-log": "^6.0.0", + "semver": "^7.3.5" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/@npmcli/name-from-folder": { + "version": "4.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/@npmcli/node-gyp": { + "version": "5.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/@npmcli/package-json": { + "version": "7.0.5", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/git": "^7.0.0", + "glob": "^13.0.0", + "hosted-git-info": "^9.0.0", + "json-parse-even-better-errors": "^5.0.0", + "proc-log": "^6.0.0", + "semver": "^7.5.3", + "spdx-expression-parse": "^4.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/@npmcli/promise-spawn": { + "version": "9.0.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "which": "^6.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/@npmcli/query": { + "version": "5.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "postcss-selector-parser": "^7.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/@npmcli/redact": { + "version": "4.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/@npmcli/run-script": { + "version": "10.0.4", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/node-gyp": "^5.0.0", + "@npmcli/package-json": "^7.0.0", + "@npmcli/promise-spawn": "^9.0.0", + "node-gyp": "^12.1.0", + "proc-log": "^6.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/@sigstore/bundle": { + "version": "4.0.0", + "dev": true, + "inBundle": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/protobuf-specs": "^0.5.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/@sigstore/core": { + "version": "3.2.1", + "dev": true, + "inBundle": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/@sigstore/protobuf-specs": { + "version": "0.5.1", + "dev": true, + "inBundle": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/@sigstore/sign": { + "version": "4.1.1", + "dev": true, + "inBundle": true, + "license": "Apache-2.0", + "dependencies": { + "@gar/promise-retry": "^1.0.2", + "@sigstore/bundle": "^4.0.0", + "@sigstore/core": "^3.2.0", + "@sigstore/protobuf-specs": "^0.5.0", + "make-fetch-happen": "^15.0.4", + "proc-log": "^6.1.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/@sigstore/tuf": { + "version": "4.0.2", + "dev": true, + "inBundle": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/protobuf-specs": "^0.5.0", + "tuf-js": "^4.1.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/@sigstore/verify": { + "version": "3.1.1", + "dev": true, + "inBundle": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/bundle": "^4.0.0", + "@sigstore/core": "^3.2.1", + "@sigstore/protobuf-specs": "^0.5.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/@tufjs/canonical-json": { + "version": "2.0.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/@tufjs/models": { + "version": "4.1.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "@tufjs/canonical-json": "2.0.0", + "minimatch": "^10.1.1" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/abbrev": { + "version": "4.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/agent-base": { + "version": "7.1.4", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/aproba": { + "version": "2.1.0", + "dev": true, + "inBundle": true, + "license": "ISC" + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/archy": { + "version": "1.0.0", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/balanced-match": { + "version": "4.0.4", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/bin-links": { + "version": "6.0.2", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "cmd-shim": "^8.0.0", + "npm-normalize-package-bin": "^5.0.0", + "proc-log": "^6.0.0", + "read-cmd-shim": "^6.0.0", + "write-file-atomic": "^7.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/binary-extensions": { + "version": "3.1.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=18.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/brace-expansion": { + "version": "5.0.7", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/cacache": { + "version": "20.0.4", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/fs": "^5.0.0", + "fs-minipass": "^3.0.0", + "glob": "^13.0.0", + "lru-cache": "^11.1.0", + "minipass": "^7.0.3", + "minipass-collect": "^2.0.1", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "p-map": "^7.0.2", + "ssri": "^13.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/chalk": { + "version": "5.6.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/chownr": { + "version": "3.0.0", + "dev": true, + "inBundle": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/ci-info": { + "version": "4.4.0", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/cidr-regex": { + "version": "5.0.5", + "dev": true, + "inBundle": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/cmd-shim": { + "version": "8.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/common-ancestor-path": { + "version": "2.0.0", + "dev": true, + "inBundle": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">= 18" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/cssesc": { + "version": "3.0.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/debug": { + "version": "4.4.3", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/diff": { + "version": "8.0.4", + "dev": true, + "inBundle": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/env-paths": { + "version": "2.2.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/exponential-backoff": { + "version": "3.1.3", + "dev": true, + "inBundle": true, + "license": "Apache-2.0" + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/fastest-levenshtein": { + "version": "1.0.16", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">= 4.9.1" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/fs-minipass": { + "version": "3.0.3", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/glob": { + "version": "13.0.6", + "dev": true, + "inBundle": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/graceful-fs": { + "version": "4.2.11", + "dev": true, + "inBundle": true, + "license": "ISC" + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/hosted-git-info": { + "version": "9.0.3", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^11.1.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/http-cache-semantics": { + "version": "4.2.0", + "dev": true, + "inBundle": true, + "license": "BSD-2-Clause" + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/http-proxy-agent": { + "version": "7.0.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/https-proxy-agent": { + "version": "7.0.6", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/iconv-lite": { + "version": "0.7.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/ignore-walk": { + "version": "8.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "minimatch": "^10.0.3" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/ini": { + "version": "6.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/init-package-json": { + "version": "8.2.5", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/package-json": "^7.0.0", + "npm-package-arg": "^13.0.0", + "promzard": "^3.0.1", + "read": "^5.0.1", + "semver": "^7.7.2", + "validate-npm-package-name": "^7.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/ip-address": { + "version": "10.2.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/is-cidr": { + "version": "6.0.4", + "dev": true, + "inBundle": true, + "license": "BSD-2-Clause", + "dependencies": { + "cidr-regex": "^5.0.4" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/isexe": { + "version": "4.0.0", + "dev": true, + "inBundle": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=20" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/json-parse-even-better-errors": { + "version": "5.0.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/json-stringify-nice": { + "version": "1.1.4", + "dev": true, + "inBundle": true, + "license": "ISC", + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/jsonparse": { + "version": "1.3.1", + "dev": true, + "engines": [ + "node >= 0.2.0" + ], + "inBundle": true, + "license": "MIT" + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/just-diff": { + "version": "6.0.2", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/just-diff-apply": { + "version": "5.5.0", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/libnpmaccess": { + "version": "10.0.3", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "npm-package-arg": "^13.0.0", + "npm-registry-fetch": "^19.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/libnpmdiff": { + "version": "8.1.11", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/arborist": "^9.9.0", + "@npmcli/installed-package-contents": "^4.0.0", + "binary-extensions": "^3.0.0", + "diff": "^8.0.2", + "minimatch": "^10.0.3", + "npm-package-arg": "^13.0.0", + "pacote": "^21.0.2", + "tar": "^7.5.1" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/libnpmexec": { + "version": "10.3.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@gar/promise-retry": "^1.0.0", + "@npmcli/arborist": "^9.9.0", + "@npmcli/package-json": "^7.0.0", + "@npmcli/run-script": "^10.0.0", + "ci-info": "^4.0.0", + "npm-package-arg": "^13.0.0", + "pacote": "^21.0.2", + "proc-log": "^6.0.0", + "read": "^5.0.1", + "semver": "^7.3.7", + "signal-exit": "^4.1.0", + "walk-up-path": "^4.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/libnpmfund": { + "version": "7.0.25", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/arborist": "^9.9.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/libnpmorg": { + "version": "8.0.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "aproba": "^2.0.0", + "npm-registry-fetch": "^19.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/libnpmpack": { + "version": "9.1.11", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/arborist": "^9.9.0", + "@npmcli/run-script": "^10.0.0", + "npm-package-arg": "^13.0.0", + "pacote": "^21.0.2" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/libnpmpublish": { + "version": "11.2.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/package-json": "^7.0.0", + "ci-info": "^4.0.0", + "npm-package-arg": "^13.0.0", + "npm-registry-fetch": "^19.0.0", + "proc-log": "^6.0.0", + "semver": "^7.3.7", + "sigstore": "^4.0.0", + "ssri": "^13.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/libnpmsearch": { + "version": "9.0.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "npm-registry-fetch": "^19.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/libnpmteam": { + "version": "8.0.2", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "aproba": "^2.0.0", + "npm-registry-fetch": "^19.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/libnpmversion": { + "version": "8.0.4", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/git": "^7.0.0", + "@npmcli/run-script": "^10.0.0", + "json-parse-even-better-errors": "^5.0.0", + "proc-log": "^6.0.0", + "semver": "^7.3.7" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/lru-cache": { + "version": "11.5.1", + "dev": true, + "inBundle": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/make-fetch-happen": { + "version": "15.0.6", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@gar/promise-retry": "^1.0.0", + "@npmcli/agent": "^4.0.0", + "@npmcli/redact": "^4.0.0", + "cacache": "^20.0.1", + "http-cache-semantics": "^4.1.1", + "minipass": "^7.0.2", + "minipass-fetch": "^5.0.0", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "negotiator": "^1.0.0", + "proc-log": "^6.0.0", + "ssri": "^13.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/minimatch": { + "version": "10.2.5", + "dev": true, + "inBundle": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/minipass": { + "version": "7.1.3", + "dev": true, + "inBundle": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/minipass-collect": { + "version": "2.0.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/minipass-fetch": { + "version": "5.0.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.0.3", + "minipass-sized": "^2.0.0", + "minizlib": "^3.0.1" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + }, + "optionalDependencies": { + "iconv-lite": "^0.7.2" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/minipass-flush": { + "version": "1.0.6", + "dev": true, + "inBundle": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minipass": "^7.1.3" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/minipass-pipeline": { + "version": "1.2.4", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/minipass-pipeline/node_modules/minipass": { + "version": "3.3.6", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/minipass-pipeline/node_modules/yallist": { + "version": "4.0.0", + "dev": true, + "inBundle": true, + "license": "ISC" + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/minipass-sized": { + "version": "2.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/minizlib": { + "version": "3.1.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/ms": { + "version": "2.1.3", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/mute-stream": { + "version": "3.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/negotiator": { + "version": "1.0.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/node-gyp": { + "version": "12.4.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.0", + "exponential-backoff": "^3.1.1", + "graceful-fs": "^4.2.6", + "nopt": "^9.0.0", + "proc-log": "^6.0.0", + "semver": "^7.3.5", + "tar": "^7.5.4", + "tinyglobby": "^0.2.12", + "undici": "^6.25.0", + "which": "^6.0.0" + }, + "bin": { + "node-gyp": "bin/node-gyp.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/nopt": { + "version": "9.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "abbrev": "^4.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/npm-audit-report": { + "version": "7.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/npm-bundled": { + "version": "5.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "npm-normalize-package-bin": "^5.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/npm-install-checks": { + "version": "8.0.0", + "dev": true, + "inBundle": true, + "license": "BSD-2-Clause", + "dependencies": { + "semver": "^7.1.1" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/npm-normalize-package-bin": { + "version": "5.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/npm-package-arg": { + "version": "13.0.2", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "hosted-git-info": "^9.0.0", + "proc-log": "^6.0.0", + "semver": "^7.3.5", + "validate-npm-package-name": "^7.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/npm-packlist": { + "version": "10.0.4", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "ignore-walk": "^8.0.0", + "proc-log": "^6.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/npm-pick-manifest": { + "version": "11.0.3", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "npm-install-checks": "^8.0.0", + "npm-normalize-package-bin": "^5.0.0", + "npm-package-arg": "^13.0.0", + "semver": "^7.3.5" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/npm-profile": { + "version": "12.0.2", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "npm-registry-fetch": "^19.0.0", + "proc-log": "^6.1.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/npm-registry-fetch": { + "version": "19.1.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/redact": "^4.0.0", + "jsonparse": "^1.3.1", + "make-fetch-happen": "^15.0.0", + "minipass": "^7.0.2", + "minipass-fetch": "^5.0.0", + "minizlib": "^3.0.1", + "npm-package-arg": "^13.0.0", + "proc-log": "^6.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/npm-user-validate": { + "version": "4.0.0", + "dev": true, + "inBundle": true, + "license": "BSD-2-Clause", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/p-map": { + "version": "7.0.4", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/pacote": { + "version": "21.5.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@gar/promise-retry": "^1.0.0", + "@npmcli/git": "^7.0.0", + "@npmcli/installed-package-contents": "^4.0.0", + "@npmcli/package-json": "^7.0.0", + "@npmcli/promise-spawn": "^9.0.0", + "@npmcli/run-script": "^10.0.0", + "cacache": "^20.0.0", + "fs-minipass": "^3.0.0", + "minipass": "^7.0.2", + "npm-package-arg": "^13.0.0", + "npm-packlist": "^10.0.1", + "npm-pick-manifest": "^11.0.1", + "npm-registry-fetch": "^19.0.0", + "proc-log": "^6.0.0", + "sigstore": "^4.0.0", + "ssri": "^13.0.0", + "tar": "^7.4.3" + }, + "bin": { + "pacote": "bin/index.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/parse-conflict-json": { + "version": "5.0.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "json-parse-even-better-errors": "^5.0.0", + "just-diff": "^6.0.0", + "just-diff-apply": "^5.2.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/path-scurry": { + "version": "2.0.2", + "dev": true, + "inBundle": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/postcss-selector-parser": { + "version": "7.1.4", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/proc-log": { + "version": "6.1.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/proggy": { + "version": "4.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/promise-all-reject-late": { + "version": "1.0.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/promise-call-limit": { + "version": "3.0.2", + "dev": true, + "inBundle": true, + "license": "ISC", + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/promzard": { + "version": "3.0.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "read": "^5.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/qrcode-terminal": { + "version": "0.12.0", + "dev": true, + "inBundle": true, + "bin": { + "qrcode-terminal": "bin/qrcode-terminal.js" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/read": { + "version": "5.0.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "mute-stream": "^3.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/read-cmd-shim": { + "version": "6.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/safer-buffer": { + "version": "2.1.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/semver": { + "version": "7.8.5", + "dev": true, + "inBundle": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/signal-exit": { + "version": "4.1.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/sigstore": { + "version": "4.1.1", + "dev": true, + "inBundle": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/bundle": "^4.0.0", + "@sigstore/core": "^3.2.1", + "@sigstore/protobuf-specs": "^0.5.0", + "@sigstore/sign": "^4.1.1", + "@sigstore/tuf": "^4.0.2", + "@sigstore/verify": "^3.1.1" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/smart-buffer": { + "version": "4.2.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/socks": { + "version": "2.8.9", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "ip-address": "^10.1.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/socks-proxy-agent": { + "version": "8.0.5", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "socks": "^2.8.3" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/spdx-exceptions": { + "version": "2.5.0", + "dev": true, + "inBundle": true, + "license": "CC-BY-3.0" + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/spdx-expression-parse": { + "version": "4.0.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/spdx-license-ids": { + "version": "3.0.23", + "dev": true, + "inBundle": true, + "license": "CC0-1.0" + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/ssri": { + "version": "13.0.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/supports-color": { + "version": "10.2.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/tar": { + "version": "7.5.19", + "dev": true, + "inBundle": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/text-table": { + "version": "0.2.0", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/tiny-relative-date": { + "version": "2.0.2", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/tinyglobby": { + "version": "0.2.17", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.4", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/treeverse": { + "version": "3.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/tuf-js": { + "version": "4.1.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "@tufjs/models": "4.1.0", + "debug": "^4.4.3", + "make-fetch-happen": "^15.0.1" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/undici": { + "version": "6.27.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/util-deprecate": { + "version": "1.0.2", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/validate-npm-package-name": { + "version": "7.0.2", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/walk-up-path": { + "version": "4.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/which": { + "version": "6.0.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "isexe": "^4.0.0" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/write-file-atomic": { + "version": "7.0.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "signal-exit": "^4.0.1" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/yallist": { + "version": "5.0.0", + "dev": true, + "inBundle": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@semantic-release/npm/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@semantic-release/npm/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@semantic-release/npm/node_modules/strip-final-newline": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-4.0.0.tgz", + "integrity": "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@semantic-release/npm/node_modules/unicorn-magic": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", + "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@semantic-release/release-notes-generator": { + "version": "14.1.1", + "resolved": "https://registry.npmjs.org/@semantic-release/release-notes-generator/-/release-notes-generator-14.1.1.tgz", + "integrity": "sha512-Pbd2e2XRMUD0OxehHpgd5/YghsE76cddkRHSoDvKLK+OCy4Ewxn49rWR631MEUU01lgwF/uyVXvbnVuu6+Z6VA==", + "dev": true, + "license": "MIT", + "dependencies": { + "conventional-changelog-angular": "^8.0.0", + "conventional-changelog-writer": "^8.0.0", + "conventional-commits-filter": "^5.0.0", + "conventional-commits-parser": "^6.0.0", + "debug": "^4.0.0", + "import-from-esm": "^2.0.0", + "lodash-es": "^4.17.21", + "read-package-up": "^11.0.0" + }, + "engines": { + "node": ">=20.8.1" + }, + "peerDependencies": { + "semantic-release": ">=20.1.0" + } + }, + "node_modules/@semantic-release/release-notes-generator/node_modules/conventional-changelog-angular": { + "version": "8.3.1", + "resolved": "https://registry.npmjs.org/conventional-changelog-angular/-/conventional-changelog-angular-8.3.1.tgz", + "integrity": "sha512-6gfI3otXK5Ph5DfCOI1dblr+kN3FAm5a97hYoQkqNZxOaYa5WKfXH+AnpsmS+iUH2mgVC2Cg2Qw9m5OKcmNrIg==", + "dev": true, + "license": "ISC", + "dependencies": { + "compare-func": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@semantic-release/release-notes-generator/node_modules/conventional-commits-parser": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/conventional-commits-parser/-/conventional-commits-parser-6.4.0.tgz", + "integrity": "sha512-tvRg7FIBNlyPzjdG8wWRlPHQJJHI7DylhtRGeU9Lq+JuoPh5BKpPRX83ZdLrvXuOSu5Eo/e7SzOQhU4Hd2Miuw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@simple-libs/stream-utils": "^1.2.0", + "meow": "^13.0.0" + }, + "bin": { + "conventional-commits-parser": "dist/cli/index.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@simple-libs/stream-utils": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@simple-libs/stream-utils/-/stream-utils-1.2.0.tgz", + "integrity": "sha512-KxXvfapcixpz6rVEB6HPjOUZT22yN6v0vI0urQSk1L8MlEWPDFCZkhw2xmkyoTGYeFw7tWTZd7e3lVzRZRN/EA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://ko-fi.com/dangreen" + } + }, + "node_modules/@sindresorhus/is": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", + "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@sindresorhus/merge-streams": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz", + "integrity": "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@types/conventional-commits-parser": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@types/conventional-commits-parser/-/conventional-commits-parser-5.0.2.tgz", + "integrity": "sha512-BgT2szDXnVypgpNxOK8aL5SGjUdaQbC++WZNjF1Qge3Og2+zhHj+RWhmehLhYyvQwqAmvezruVfOf8+3m74W+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/node": { + "version": "26.0.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.0.1.tgz", + "integrity": "sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/@types/normalize-package-data": { + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz", + "integrity": "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==", + "dev": true, + "license": "MIT" + }, + "node_modules/agent-base": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-9.0.0.tgz", + "integrity": "sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-escapes": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", + "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "environment": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/argv-formatter": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/argv-formatter/-/argv-formatter-1.0.0.tgz", + "integrity": "sha512-F2+Hkm9xFaRg+GkaNnbwXNDV5O6pnCFEmqyhvfC/Ic5LbgOWjJh3L+mN/s91rxVL3znE7DYVpW0GJFT+4YBgWw==", + "dev": true, + "license": "MIT" + }, + "node_modules/array-ify": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/array-ify/-/array-ify-1.0.0.tgz", + "integrity": "sha512-c5AMf34bKdvPhQ7tBGhqkgKNUzMr4WUs+WDtC2ZUGOUncbxKMTvqxYctiseW3+L4bA8ec+GcZ6/A/FW4m8ukng==", + "dev": true, + "license": "MIT" + }, + "node_modules/before-after-hook": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-4.0.0.tgz", + "integrity": "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/bottleneck": { + "version": "2.19.5", + "resolved": "https://registry.npmjs.org/bottleneck/-/bottleneck-2.19.5.tgz", + "integrity": "sha512-VHiNCbI1lKdl44tGrhNfU3lup0Tj/ZBMJB5/2ZbNXRCPuRCO7ed2mgcK4r17y+KB2EfuYuRaVlwNbAeaWGSpbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/char-regex": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", + "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/cli-cursor": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", + "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-highlight": { + "version": "2.1.11", + "resolved": "https://registry.npmjs.org/cli-highlight/-/cli-highlight-2.1.11.tgz", + "integrity": "sha512-9KDcoEVwyUXrjcJNvHD0NFc/hiwe/WPVYIleQh2O1N2Zro5gWJZ/K+3DGn8w8P/F6FxOgzyC5bxDyHIgCSPhGg==", + "dev": true, + "license": "ISC", + "dependencies": { + "chalk": "^4.0.0", + "highlight.js": "^10.7.1", + "mz": "^2.4.0", + "parse5": "^5.1.1", + "parse5-htmlparser2-tree-adapter": "^6.0.0", + "yargs": "^16.0.0" + }, + "bin": { + "highlight": "bin/highlight" + }, + "engines": { + "node": ">=8.0.0", + "npm": ">=5.0.0" + } + }, + "node_modules/cli-highlight/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-highlight/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/cli-highlight/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/cli-highlight/node_modules/cliui": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", + "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^7.0.0" + } + }, + "node_modules/cli-highlight/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/cli-highlight/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-highlight/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-highlight/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-highlight/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/cli-highlight/node_modules/yargs": { + "version": "16.2.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.2.tgz", + "integrity": "sha512-Nt9ZJjXTv5R8MHbqby/wXQ6Gi0Bb3TcYZkR1bzuL4yB2OxWPkXknz513gEF0GoA6tn00UpbPvERW8rzCuWCA6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^7.0.2", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^20.2.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/cli-highlight/node_modules/yargs-parser": { + "version": "20.2.9", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", + "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/cli-table3": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/cli-table3/-/cli-table3-0.6.5.tgz", + "integrity": "sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "string-width": "^4.2.0" + }, + "engines": { + "node": "10.* || >= 12.*" + }, + "optionalDependencies": { + "@colors/colors": "1.5.0" + } + }, + "node_modules/cli-table3/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-table3/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/cli-table3/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-table3/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-table3/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-truncate": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-4.0.0.tgz", + "integrity": "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==", + "dev": true, + "license": "MIT", + "dependencies": { + "slice-ansi": "^5.0.0", + "string-width": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cliui/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/cliui/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/cliui/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/commander": { + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz", + "integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/compare-func": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/compare-func/-/compare-func-2.0.0.tgz", + "integrity": "sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-ify": "^1.0.0", + "dot-prop": "^5.1.0" + } + }, + "node_modules/config-chain": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz", + "integrity": "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ini": "^1.3.4", + "proto-list": "~1.2.1" + } + }, + "node_modules/config-chain/node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, + "license": "ISC" + }, + "node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/conventional-changelog-angular": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/conventional-changelog-angular/-/conventional-changelog-angular-7.0.0.tgz", + "integrity": "sha512-ROjNchA9LgfNMTTFSIWPzebCwOGFdgkEq45EnvvrmSLvCtAw0HSmrCs7/ty+wAeYUZyNay0YMUNYFTRL72PkBQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "compare-func": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/conventional-changelog-conventionalcommits": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/conventional-changelog-conventionalcommits/-/conventional-changelog-conventionalcommits-7.0.2.tgz", + "integrity": "sha512-NKXYmMR/Hr1DevQegFB4MwfM5Vv0m4UIxKZTTYuD98lpTknaZlSRrDOG4X7wIXpGkfsYxZTghUN+Qq+T0YQI7w==", + "dev": true, + "license": "ISC", + "dependencies": { + "compare-func": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/conventional-changelog-writer": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/conventional-changelog-writer/-/conventional-changelog-writer-8.4.0.tgz", + "integrity": "sha512-HHBFkk1EECxxmCi4CTu091iuDpQv5/OavuCUAuZmrkWpmYfyD816nom1CvtfXJ/uYfAAjavgHvXHX291tSLK8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@simple-libs/stream-utils": "^1.2.0", + "conventional-commits-filter": "^5.0.0", + "handlebars": "^4.7.7", + "meow": "^13.0.0", + "semver": "^7.5.2" + }, + "bin": { + "conventional-changelog-writer": "dist/cli/index.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/conventional-commits-filter": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/conventional-commits-filter/-/conventional-commits-filter-5.0.0.tgz", + "integrity": "sha512-tQMagCOC59EVgNZcC5zl7XqO30Wki9i9J3acbUvkaosCT6JX3EeFwJD7Qqp4MCikRnzS18WXV3BLIQ66ytu6+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/conventional-commits-parser": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/conventional-commits-parser/-/conventional-commits-parser-5.0.0.tgz", + "integrity": "sha512-ZPMl0ZJbw74iS9LuX9YIAiW8pfM5p3yh2o/NbXHbkFuZzY5jvdi5jFycEOkmBW5H5I7nA+D6f3UcsCLP2vvSEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-text-path": "^2.0.0", + "JSONStream": "^1.3.5", + "meow": "^12.0.1", + "split2": "^4.0.0" + }, + "bin": { + "conventional-commits-parser": "cli.mjs" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/conventional-commits-parser/node_modules/meow": { + "version": "12.1.1", + "resolved": "https://registry.npmjs.org/meow/-/meow-12.1.1.tgz", + "integrity": "sha512-BhXM0Au22RwUneMPwSCnyhTOizdWoIEPU9sp0Aqa1PnDMR5Wv2FGXYDjuzJEIX+Eo2Rb8xuYe5jrnm5QowQFkw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/convert-hrtime": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/convert-hrtime/-/convert-hrtime-5.0.0.tgz", + "integrity": "sha512-lOETlkIeYSJWcbbcvjRKGxVMXJR+8+OQb/mTPbA4ObPMytYIsUbuOE0Jzy60hjARYszq1id0j8KgVhC+WGZVTg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/cosmiconfig": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.2.tgz", + "integrity": "sha512-gtTZxTDau1wL7Y7zifc2dd8jHSK/k6BTx/2Xp/BpdlAdnlYWFVt7qhJqgwi7637yRwRQ3qL4ZidbB4I8tA5VOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.1", + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/cosmiconfig-typescript-loader": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/cosmiconfig-typescript-loader/-/cosmiconfig-typescript-loader-6.3.0.tgz", + "integrity": "sha512-Akr82WH1Wfqatyiqpj8HDkO2o2KmJRu1FhKfSNJP3K4IdXwHfEyL7MOb62i1AGQVLtIQM+iCE9CGOtrfhR+mmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jiti": "2.6.1" + }, + "engines": { + "node": ">=v18" + }, + "peerDependencies": { + "@types/node": "*", + "cosmiconfig": ">=9", + "typescript": ">=5" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/crypto-random-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-4.0.0.tgz", + "integrity": "sha512-x8dy3RnvYdlUcPOjkEHqozhiwzKNSq7GcPuXFbnyMOCHxX8V3OgIg/pYuabl2sbUPfIJaeAQB7PMOK8DFIdoRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^1.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/crypto-random-string/node_modules/type-fest": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-1.4.0.tgz", + "integrity": "sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/dargs": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/dargs/-/dargs-8.1.0.tgz", + "integrity": "sha512-wAV9QHOsNbwnWdNW2FYvE1P56wtgSbM+3SZcdGiWQILwVjACCXDCI3Ai8QlCjMDB8YK5zySiXZYBiwGmNY3lnw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/dot-prop": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-5.3.0.tgz", + "integrity": "sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-obj": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/duplexer2": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz", + "integrity": "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "readable-stream": "^2.0.2" + } + }, + "node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/emojilib": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/emojilib/-/emojilib-2.4.0.tgz", + "integrity": "sha512-5U0rVMU5Y2n2+ykNLQqMoqklN9ICBT/KsvC1Gz6vqHbz2AXXGkG+Pm5rMWk/8Vjrr/mY9985Hi8DYzn1F09Nyw==", + "dev": true, + "license": "MIT" + }, + "node_modules/env-ci": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/env-ci/-/env-ci-11.2.0.tgz", + "integrity": "sha512-D5kWfzkmaOQDioPmiviWAVtKmpPT4/iJmMVQxWxMPJTFyTkdc5JQUfc5iXEeWxcOdsYTKSAiA/Age4NUOqKsRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "execa": "^8.0.0", + "java-properties": "^1.0.2" + }, + "engines": { + "node": "^18.17 || >=20.6.1" + } + }, + "node_modules/env-ci/node_modules/execa": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", + "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^8.0.1", + "human-signals": "^5.0.0", + "is-stream": "^3.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^5.1.0", + "onetime": "^6.0.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^3.0.0" + }, + "engines": { + "node": ">=16.17" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/env-ci/node_modules/get-stream": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", + "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/env-ci/node_modules/human-signals": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", + "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=16.17.0" + } + }, + "node_modules/env-ci/node_modules/is-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", + "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/env-ci/node_modules/mimic-fn": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", + "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/env-ci/node_modules/npm-run-path": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", + "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/env-ci/node_modules/onetime": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", + "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/env-ci/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/env-ci/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/env-ci/node_modules/strip-final-newline": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", + "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/environment": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", + "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/figures": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz", + "integrity": "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-unicode-supported": "^2.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-7.0.0.tgz", + "integrity": "sha512-YyZM99iHrqLKjmt4LJDj58KI+fYyufRLBSYcqycxf//KpBk9FoewoGX0450m9nB44qrZnovzC2oeP5hUibxc/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^7.2.0", + "path-exists": "^5.0.0", + "unicorn-magic": "^0.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/find-up-simple": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/find-up-simple/-/find-up-simple-1.0.1.tgz", + "integrity": "sha512-afd4O7zpqHeRyg4PfDQsXmlDe2PfdHtJt6Akt8jOWaApLOZk5JXs6VMR29lz03pRe9mpykrRCYIYxaJYcfpncQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/find-versions": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/find-versions/-/find-versions-6.0.0.tgz", + "integrity": "sha512-2kCCtc+JvcZ86IGAz3Z2Y0A1baIz9fL31pH/0S1IqZr9Iwnjq8izfPtrCyQKO6TLMPELLsQMre7VDqeIKCsHkA==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver-regex": "^4.0.5", + "super-regex": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/fs-extra": { + "version": "11.3.5", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.5.tgz", + "integrity": "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/function-timeout": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/function-timeout/-/function-timeout-1.0.2.tgz", + "integrity": "sha512-939eZS4gJ3htTHAldmyyuzlrD58P03fHG49v2JfFXbV6OhvZKRC9j2yAtdHw/zrp2zXHuv05zMIy40F0ge7spA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/git-log-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/git-log-parser/-/git-log-parser-1.2.1.tgz", + "integrity": "sha512-PI+sPDvHXNPl5WNOErAK05s3j0lgwUzMN6o8cyQrDaKfT3qd7TmNJKeXX+SknI5I0QhG5fVPAEwSY4tRGDtYoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "argv-formatter": "~1.0.0", + "spawn-error-forwarder": "~1.0.0", + "split2": "~1.0.0", + "stream-combiner2": "~1.1.1", + "through2": "~2.0.0", + "traverse": "0.6.8" + } + }, + "node_modules/git-log-parser/node_modules/split2": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-1.0.0.tgz", + "integrity": "sha512-NKywug4u4pX/AZBB1FCPzZ6/7O+Xhz1qMVbzTvvKvikjO99oPN87SkK08mEY9P63/5lWjK+wgOOgApnTg5r6qg==", + "dev": true, + "license": "ISC", + "dependencies": { + "through2": "~2.0.0" + } + }, + "node_modules/git-raw-commits": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/git-raw-commits/-/git-raw-commits-4.0.0.tgz", + "integrity": "sha512-ICsMM1Wk8xSGMowkOmPrzo2Fgmfo4bMHLNX6ytHjajRJUqvHOw/TFapQ+QG75c3X/tTDDhOSRPGC52dDbNM8FQ==", + "deprecated": "Deprecated and no longer maintained. Use @conventional-changelog/git-client instead.", + "dev": true, + "license": "MIT", + "dependencies": { + "dargs": "^8.0.0", + "meow": "^12.0.1", + "split2": "^4.0.0" + }, + "bin": { + "git-raw-commits": "cli.mjs" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/git-raw-commits/node_modules/meow": { + "version": "12.1.1", + "resolved": "https://registry.npmjs.org/meow/-/meow-12.1.1.tgz", + "integrity": "sha512-BhXM0Au22RwUneMPwSCnyhTOizdWoIEPU9sp0Aqa1PnDMR5Wv2FGXYDjuzJEIX+Eo2Rb8xuYe5jrnm5QowQFkw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/global-directory": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/global-directory/-/global-directory-4.0.1.tgz", + "integrity": "sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ini": "4.1.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/handlebars": { + "version": "4.7.9", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz", + "integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.5", + "neo-async": "^2.6.2", + "source-map": "^0.6.1", + "wordwrap": "^1.0.0" + }, + "bin": { + "handlebars": "bin/handlebars" + }, + "engines": { + "node": ">=0.4.7" + }, + "optionalDependencies": { + "uglify-js": "^3.1.4" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/highlight.js": { + "version": "10.7.3", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", + "integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, + "node_modules/hook-std": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/hook-std/-/hook-std-4.0.0.tgz", + "integrity": "sha512-IHI4bEVOt3vRUDJ+bFA9VUJlo7SzvFARPNLw75pqSmAOP2HmTWfFJtPvLBrDrlgjEYXY9zs7SFdHPQaJShkSCQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/hosted-git-info": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.3.tgz", + "integrity": "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg==", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^11.1.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/http-proxy-agent": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-9.1.0.tgz", + "integrity": "sha512-2NxoveTT58mjYT4n3RPTEfCZGLMbidoO8XEieXfpSYxu+PQJ1qpx4ypwH6N+uF9twBPIvRRgvkvW5HUTYWENig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "9.0.0", + "debug": "^4.3.4", + "proxy-agent-negotiate": "1.1.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/https-proxy-agent": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-9.1.0.tgz", + "integrity": "sha512-ag87y7cJJ9/3+GxFr8Oy4O5faDsGRGnBGsJj/YjOSsSx/5eadKLYTMPlzuR6obgoCDDm0abAAZitXXQkMOPSpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "9.0.0", + "debug": "^4.3.4", + "proxy-agent-negotiate": "1.1.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/husky": { + "version": "9.1.7", + "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", + "integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==", + "dev": true, + "license": "MIT", + "bin": { + "husky": "bin.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/typicode" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/import-fresh/node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/import-from-esm": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/import-from-esm/-/import-from-esm-2.0.0.tgz", + "integrity": "sha512-YVt14UZCgsX1vZQ3gKjkWVdBdHQ6eu3MPU1TBgL1H5orXe2+jWD006WCPPtOuwlQm10NuzOW5WawiF1Q9veW8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.4", + "import-meta-resolve": "^4.0.0" + }, + "engines": { + "node": ">=18.20" + } + }, + "node_modules/import-meta-resolve": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", + "integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/index-to-position": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz", + "integrity": "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ini": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.1.tgz", + "integrity": "sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-fullwidth-code-point": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz", + "integrity": "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-obj": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-2.0.0.tgz", + "integrity": "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-text-path": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-text-path/-/is-text-path-2.0.0.tgz", + "integrity": "sha512-+oDTluR6WEjdXEJMnC2z6A4FRwFoYuvShVVEGsS7ewc0UTi2QtAKMDJuL4BDEVt+5T7MjFo12RP8ghOM75oKJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "text-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-unicode-supported": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", + "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/issue-parser": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/issue-parser/-/issue-parser-7.0.2.tgz", + "integrity": "sha512-7atWPjhGEIX3JEtMrOYd8TKzboYlq+5sNbdl9POiLYOI14G5HZiQbZP0Xj5EZdrufQVXfJlpTV0hys0CuxwxZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lodash.capitalize": "^4.2.1", + "lodash.escaperegexp": "^4.1.2", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.uniqby": "^4.7.0" + }, + "engines": { + "node": "^18.17 || >=20.6.1" + } + }, + "node_modules/java-properties": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/java-properties/-/java-properties-1.0.2.tgz", + "integrity": "sha512-qjdpeo2yKlYTH7nFdK0vbZWuTCesk4o63v5iVOlhMQPfuIZQfW/HI35SjfhA+4qpg36rnFSvUK5b1m+ckIblQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-parse-better-errors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz", + "integrity": "sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-with-bigint": { + "version": "3.5.8", + "resolved": "https://registry.npmjs.org/json-with-bigint/-/json-with-bigint-3.5.8.tgz", + "integrity": "sha512-eq/4KP6K34kwa7TcFdtvnftvHCD9KvHOGGICWwMFc4dOOKF5t4iYqnfLK8otCRCRv06FXOzGGyqE8h8ElMvvdw==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jsonparse": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz", + "integrity": "sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==", + "dev": true, + "engines": [ + "node >= 0.2.0" + ], + "license": "MIT" + }, + "node_modules/JSONStream": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/JSONStream/-/JSONStream-1.3.5.tgz", + "integrity": "sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ==", + "dev": true, + "license": "(MIT OR Apache-2.0)", + "dependencies": { + "jsonparse": "^1.2.0", + "through": ">=2.2.7 <3" + }, + "bin": { + "JSONStream": "bin.js" + }, + "engines": { + "node": "*" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lint-staged": { + "version": "15.5.2", + "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-15.5.2.tgz", + "integrity": "sha512-YUSOLq9VeRNAo/CTaVmhGDKG+LBtA8KF1X4K5+ykMSwWST1vDxJRB2kv2COgLb1fvpCo+A/y9A0G0znNVmdx4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^5.4.1", + "commander": "^13.1.0", + "debug": "^4.4.0", + "execa": "^8.0.1", + "lilconfig": "^3.1.3", + "listr2": "^8.2.5", + "micromatch": "^4.0.8", + "pidtree": "^0.6.0", + "string-argv": "^0.3.2", + "yaml": "^2.7.0" + }, + "bin": { + "lint-staged": "bin/lint-staged.js" + }, + "engines": { + "node": ">=18.12.0" + }, + "funding": { + "url": "https://opencollective.com/lint-staged" + } + }, + "node_modules/lint-staged/node_modules/execa": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", + "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^8.0.1", + "human-signals": "^5.0.0", + "is-stream": "^3.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^5.1.0", + "onetime": "^6.0.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^3.0.0" + }, + "engines": { + "node": ">=16.17" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/lint-staged/node_modules/get-stream": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", + "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lint-staged/node_modules/human-signals": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", + "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=16.17.0" + } + }, + "node_modules/lint-staged/node_modules/is-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", + "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lint-staged/node_modules/mimic-fn": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", + "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lint-staged/node_modules/npm-run-path": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", + "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lint-staged/node_modules/onetime": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", + "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lint-staged/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lint-staged/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/lint-staged/node_modules/strip-final-newline": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", + "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/listr2": { + "version": "8.3.3", + "resolved": "https://registry.npmjs.org/listr2/-/listr2-8.3.3.tgz", + "integrity": "sha512-LWzX2KsqcB1wqQ4AHgYb4RsDXauQiqhjLk+6hjbaeHG4zpjjVAB6wC/gz6X0l+Du1cN3pUB5ZlrvTbhGSNnUQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "cli-truncate": "^4.0.0", + "colorette": "^2.0.20", + "eventemitter3": "^5.0.1", + "log-update": "^6.1.0", + "rfdc": "^1.4.1", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/load-json-file": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-4.0.0.tgz", + "integrity": "sha512-Kx8hMakjX03tiGTLAIdJ+lL0htKnXjEZN6hk/tozf/WOuYGdZBJrZ+rCJRbVCugsjB3jMLn9746NsQIf5VjBMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.1.2", + "parse-json": "^4.0.0", + "pify": "^3.0.0", + "strip-bom": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/load-json-file/node_modules/parse-json": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-4.0.0.tgz", + "integrity": "sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "error-ex": "^1.3.1", + "json-parse-better-errors": "^1.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/locate-path": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-7.2.0.tgz", + "integrity": "sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^6.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash-es": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.capitalize": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/lodash.capitalize/-/lodash.capitalize-4.2.1.tgz", + "integrity": "sha512-kZzYOKspf8XVX5AvmQF94gQW0lejFVgb80G85bU4ZWzoJ6C03PQg3coYAUpSTpQWelrZELd3XWgHzw4Ck5kaIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.escaperegexp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz", + "integrity": "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.kebabcase": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.kebabcase/-/lodash.kebabcase-4.1.1.tgz", + "integrity": "sha512-N8XRTIMMqqDgSy4VLKPnJ/+hpGZN+PHQiJnSenYqPaVV/NCqEogTnAdZLQiGKhxX+JCs8waWq2t1XHWKOmlY8g==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.mergewith": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz", + "integrity": "sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.snakecase": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz", + "integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.startcase": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.startcase/-/lodash.startcase-4.4.0.tgz", + "integrity": "sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.uniq": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", + "integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.uniqby": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/lodash.uniqby/-/lodash.uniqby-4.7.0.tgz", + "integrity": "sha512-e/zcLx6CSbmaEgFHCA7BnoQKyCtKMxnuWrJygbwPs/AIn+IMKl66L8/s+wBUn5LRw2pZx3bUHibiV1b6aTWIww==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.upperfirst": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/lodash.upperfirst/-/lodash.upperfirst-4.3.1.tgz", + "integrity": "sha512-sReKOYJIJf74dhJONhU4e0/shzi1trVbSWDOhKYE5XV2O+H7Sb2Dihwuc7xWxVl+DgFPyTqIN3zMfT9cq5iWDg==", + "dev": true, + "license": "MIT" + }, + "node_modules/log-update": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", + "integrity": "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-escapes": "^7.0.0", + "cli-cursor": "^5.0.0", + "slice-ansi": "^7.1.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/is-fullwidth-code-point": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", + "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.3.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/slice-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", + "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/lru-cache": { + "version": "11.5.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/make-asynchronous": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/make-asynchronous/-/make-asynchronous-1.1.0.tgz", + "integrity": "sha512-ayF7iT+44LXdxJLTrTd3TLQpFDDvPCBxXxbv+pMUSuHA5Q8zyAfwkRP6aHHwNVFBUFWtxAHqwNJxF8vMZLAbVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-event": "^6.0.0", + "type-fest": "^4.6.0", + "web-worker": "^1.5.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/marked": { + "version": "15.0.12", + "resolved": "https://registry.npmjs.org/marked/-/marked-15.0.12.tgz", + "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==", + "dev": true, + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/marked-terminal": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/marked-terminal/-/marked-terminal-7.3.0.tgz", + "integrity": "sha512-t4rBvPsHc57uE/2nJOLmMbZCQ4tgAccAED3ngXQqW6g+TxA488JzJ+FK3lQkzBQOI1mRV/r/Kq+1ZlJ4D0owQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-escapes": "^7.0.0", + "ansi-regex": "^6.1.0", + "chalk": "^5.4.1", + "cli-highlight": "^2.1.11", + "cli-table3": "^0.6.5", + "node-emoji": "^2.2.0", + "supports-hyperlinks": "^3.1.0" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "marked": ">=1 <16" + } + }, + "node_modules/meow": { + "version": "13.2.0", + "resolved": "https://registry.npmjs.org/meow/-/meow-13.2.0.tgz", + "integrity": "sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-4.1.0.tgz", + "integrity": "sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw==", + "dev": true, + "funding": [ + "https://github.com/sponsors/broofa" + ], + "license": "MIT", + "bin": { + "mime": "bin/cli.js" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true, + "license": "MIT" + }, + "node_modules/nerf-dart": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/nerf-dart/-/nerf-dart-1.0.0.tgz", + "integrity": "sha512-EZSPZB70jiVsivaBLYDCyntd5eH8NTSMOn3rB+HxwdmKThGELLdYv8qVIMWvZEFy9w8ZZpW9h9OB32l1rGtj7g==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-emoji": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/node-emoji/-/node-emoji-2.2.0.tgz", + "integrity": "sha512-Z3lTE9pLaJF47NyMhd4ww1yFTAP8YhYI8SleJiHzM46Fgpm5cnNzSl9XfzFNqbaz+VlJrIj3fXQ4DeN1Rjm6cw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/is": "^4.6.0", + "char-regex": "^1.0.2", + "emojilib": "^2.4.0", + "skin-tone": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/normalize-package-data": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-8.0.0.tgz", + "integrity": "sha512-RWk+PI433eESQ7ounYxIp67CYuVsS1uYSonX3kA6ps/3LWfjVQa/ptEg6Y3T6uAMq1mWpX9PQ+qx+QaHpsc7gQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "hosted-git-info": "^9.0.0", + "semver": "^7.3.5", + "validate-npm-package-license": "^3.0.4" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/normalize-url": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-9.0.1.tgz", + "integrity": "sha512-ARftfC5HdUNu9jJeL8pHj8debUIHA2b91FizCoMzY4lG6dDX13jdvTK0TBe24IBDRf2HvJSzzwEPvmbkQWHRSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/p-each-series": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-each-series/-/p-each-series-3.0.0.tgz", + "integrity": "sha512-lastgtAdoH9YaLyDa5i5z64q+kzOcQHsQ5SsZJD3q0VEyI8mq872S3geuNbRUQLVAE9siMfgKrpj7MloKFHruw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-event": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/p-event/-/p-event-6.0.1.tgz", + "integrity": "sha512-Q6Bekk5wpzW5qIyUP4gdMEujObYstZl6DMMOSenwBvV0BlE5LkDwkjs5yHbZmdCEq2o4RJx4tE1vwxFVf2FG1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-timeout": "^6.1.2" + }, + "engines": { + "node": ">=16.17" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-filter": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-filter/-/p-filter-4.1.0.tgz", + "integrity": "sha512-37/tPdZ3oJwHaS3gNJdenCDB3Tz26i9sjhnguBtvN0vYlRIiDNnvTWkuh+0hETV9rLPdJ3rlL3yVOYPIAnM8rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-map": "^7.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-limit": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-4.0.0.tgz", + "integrity": "sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^1.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-6.0.0.tgz", + "integrity": "sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-map": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.4.tgz", + "integrity": "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-timeout": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-6.1.4.tgz", + "integrity": "sha512-MyIV3ZA/PmyBN/ud8vV9XzwTrNtR4jFrObymZYnZqMmW0zA8Z17vnT0rBgFE/TlohB+YCHqXMgZzb3Csp49vqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-try": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-1.0.0.tgz", + "integrity": "sha512-U1etNYuMJoIz3ZXSrrySFjsXQTWOx2/jdi86L+2pRvph/qMKL6sbcCYdH23fqsbm8TH2Gn0OybpT4eSFlCVHww==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse-ms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-4.0.0.tgz", + "integrity": "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse5": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-5.1.1.tgz", + "integrity": "sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug==", + "dev": true, + "license": "MIT" + }, + "node_modules/parse5-htmlparser2-tree-adapter": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-6.0.1.tgz", + "integrity": "sha512-qPuWvbLgvDGilKc5BoicRovlT4MtYT6JfJyBOMDsKoiT+GiuP5qyrPCnR9HcPECIJJmZh5jRndyNThnhhb/vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "parse5": "^6.0.1" + } + }, + "node_modules/parse5-htmlparser2-tree-adapter/node_modules/parse5": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-6.0.1.tgz", + "integrity": "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-exists": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-5.0.0.tgz", + "integrity": "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pidtree": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/pidtree/-/pidtree-0.6.1.tgz", + "integrity": "sha512-e0F9AOF1JMrCfBsyJOwU9lNvQ0WtXTq0j/4jk0BQ5JSI9VAybPXmDpPRw/2FQ3e5d3ZFN1mLh7jW99m/jjaptw==", + "dev": true, + "license": "MIT", + "bin": { + "pidtree": "bin/pidtree.js" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/pify": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", + "integrity": "sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/pkg-conf": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/pkg-conf/-/pkg-conf-2.1.0.tgz", + "integrity": "sha512-C+VUP+8jis7EsQZIhDYmS5qlNtjv2yP4SNtjXK9AP1ZcTRlnSfuumaTnRfYZnYgUUYVIKqL0fRvmUGDV2fmp6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^2.0.0", + "load-json-file": "^4.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pkg-conf/node_modules/find-up": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-2.1.0.tgz", + "integrity": "sha512-NWzkk0jSJtTt08+FBFMvXoeZnOJD+jTtsRmBYbAIzJdX6l7dLgR7CTubCM5/eDdPUBvLCeVasP1brfVR/9/EZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pkg-conf/node_modules/locate-path": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-2.0.0.tgz", + "integrity": "sha512-NCI2kiDkyR7VeEKm27Kda/iQHyKJe1Bu0FlTbYp3CqJu+9IFe9bLyAjMxf5ZDDbEg+iMPzB5zYyUTSm8wVTKmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^2.0.0", + "path-exists": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pkg-conf/node_modules/p-limit": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-1.3.0.tgz", + "integrity": "sha512-vvcXsLAJ9Dr5rQOPk7toZQZJApBl2K4J6dANSsEuh6QI41JYcsS/qhTGa9ErIUUgK3WNQoJYvylxvjqmiqEA9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pkg-conf/node_modules/p-locate": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-2.0.0.tgz", + "integrity": "sha512-nQja7m7gSKuewoVRen45CtVfODR3crN3goVQ0DDZ9N3yHxgpkuBhZqsaiotSQRrADUrne346peY7kT3TSACykg==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pkg-conf/node_modules/path-exists": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", + "integrity": "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/pretty-ms": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.0.tgz", + "integrity": "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parse-ms": "^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "dev": true, + "license": "MIT" + }, + "node_modules/proto-list": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", + "integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==", + "dev": true, + "license": "ISC" + }, + "node_modules/proxy-agent-negotiate": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-agent-negotiate/-/proxy-agent-negotiate-1.1.0.tgz", + "integrity": "sha512-N8IBcM3UgCVzz2L2Lqv8DVntDnnC8/hiV4nEDUPkqq72TPUgYWjQc+bdZlBPZK9LzPAvOY//gAt0S0DApoOXWQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "kerberos": "^2.0.0" + }, + "peerDependenciesMeta": { + "kerberos": { + "optional": true + } + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "dev": true, + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/rc/node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, + "license": "ISC" + }, + "node_modules/read-package-up": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/read-package-up/-/read-package-up-11.0.0.tgz", + "integrity": "sha512-MbgfoNPANMdb4oRBNg5eqLbB2t2r+o5Ua1pNt8BqGp4I0FJZhuVSOj3PaBPni4azWuSzEdNn2evevzVmEk1ohQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up-simple": "^1.0.0", + "read-pkg": "^9.0.0", + "type-fest": "^4.6.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/read-package-up/node_modules/hosted-git-info": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.2.tgz", + "integrity": "sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^10.0.1" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/read-package-up/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/read-package-up/node_modules/normalize-package-data": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-6.0.2.tgz", + "integrity": "sha512-V6gygoYb/5EmNI+MEGrWkC+e6+Rr7mTmfHrxDbLzxQogBkgzo76rkok0Am6thgSF7Mv2nLOajAJj5vDJZEFn7g==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "hosted-git-info": "^7.0.0", + "semver": "^7.3.5", + "validate-npm-package-license": "^3.0.4" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/read-package-up/node_modules/parse-json": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz", + "integrity": "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.26.2", + "index-to-position": "^1.1.0", + "type-fest": "^4.39.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/read-package-up/node_modules/read-pkg": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-9.0.1.tgz", + "integrity": "sha512-9viLL4/n1BJUCT1NXVTdS1jtm80yDEgR5T4yCelII49Mbj0v1rZdKqj7zCiYdbB0CuCgdrvHcNogAKTFPBocFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/normalize-package-data": "^2.4.3", + "normalize-package-data": "^6.0.0", + "parse-json": "^8.0.0", + "type-fest": "^4.6.0", + "unicorn-magic": "^0.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/read-pkg": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-10.1.0.tgz", + "integrity": "sha512-I8g2lArQiP78ll51UeMZojewtYgIRCKCWqZEgOO8c/uefTI+XDXvCSXu3+YNUaTNvZzobrL5+SqHjBrByRRTdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/normalize-package-data": "^2.4.4", + "normalize-package-data": "^8.0.0", + "parse-json": "^8.3.0", + "type-fest": "^5.4.4", + "unicorn-magic": "^0.4.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/read-pkg/node_modules/parse-json": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz", + "integrity": "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.26.2", + "index-to-position": "^1.1.0", + "type-fest": "^4.39.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/read-pkg/node_modules/parse-json/node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/read-pkg/node_modules/type-fest": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.7.0.tgz", + "integrity": "sha512-1URUxUqfHFM1c+zfSPsa3gnkO7Aq21qyH75SIduNYz4SzY964rn1X2vCMQaHSHhktiw+0kPa2iyb6PUpXqB6Vg==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/read-pkg/node_modules/unicorn-magic": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.4.0.tgz", + "integrity": "sha512-wH590V9VNgYH9g3lH9wWjTrUoKsjLF6sGLjhR4sH1LWpLmCOH0Zf7PukhDA8BiS7KHe4oPNkcTHqYkj7SOGUOw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/registry-auth-token": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/registry-auth-token/-/registry-auth-token-5.1.1.tgz", + "integrity": "sha512-P7B4+jq8DeD2nMsAcdfaqHbssgHtZ7Z5+++a5ask90fvmJ8p5je4mOa+wzu+DB4vQ5tdJV/xywY+UnVFeQLV5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@pnpm/npm-conf": "^3.0.2" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/restore-cursor": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", + "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", + "dev": true, + "license": "MIT", + "dependencies": { + "onetime": "^7.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/restore-cursor/node_modules/onetime": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", + "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-function": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/restore-cursor/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "dev": true, + "license": "MIT" + }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/semantic-release": { + "version": "25.0.5", + "resolved": "https://registry.npmjs.org/semantic-release/-/semantic-release-25.0.5.tgz", + "integrity": "sha512-mn61SUJwtM8ThrWn2WmgLVpwVJeG/hPSupua1psdMoufmwRIPyvRLkRkL0JDXkP67OntlLWUYnBnfVc8EDO3/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@semantic-release/commit-analyzer": "^13.0.1", + "@semantic-release/error": "^4.0.0", + "@semantic-release/github": "^12.0.0", + "@semantic-release/npm": "^13.1.1", + "@semantic-release/release-notes-generator": "^14.1.0", + "aggregate-error": "^5.0.0", + "cosmiconfig": "^9.0.0", + "debug": "^4.0.0", + "env-ci": "^11.0.0", + "execa": "^9.0.0", + "figures": "^6.0.0", + "find-versions": "^6.0.0", + "get-stream": "^6.0.0", + "git-log-parser": "^1.2.0", + "hook-std": "^4.0.0", + "hosted-git-info": "^9.0.0", + "import-from-esm": "^2.0.0", + "lodash-es": "^4.17.21", + "marked": "^15.0.0", + "marked-terminal": "^7.3.0", + "micromatch": "^4.0.2", + "p-each-series": "^3.0.0", + "p-reduce": "^3.0.0", + "read-package-up": "^12.0.0", + "resolve-from": "^5.0.0", + "semver": "^7.3.2", + "signale": "^1.2.1", + "yargs": "^18.0.0" + }, + "bin": { + "semantic-release": "bin/semantic-release.js" + }, + "engines": { + "node": "^22.14.0 || >= 24.10.0" + } + }, + "node_modules/semantic-release/node_modules/@semantic-release/error": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@semantic-release/error/-/error-4.0.0.tgz", + "integrity": "sha512-mgdxrHTLOjOddRVYIYDo0fR3/v61GNN1YGkfbrjuIKg/uMgCd+Qzo3UAXJ+woLQQpos4pl5Esuw5A7AoNlzjUQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/semantic-release/node_modules/aggregate-error": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-5.0.0.tgz", + "integrity": "sha512-gOsf2YwSlleG6IjRYG2A7k0HmBMEo6qVNk9Bp/EaLgAJT5ngH6PXbqa4ItvnEwCm/velL5jAnQgsHsWnjhGmvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "clean-stack": "^5.2.0", + "indent-string": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/semantic-release/node_modules/clean-stack": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-5.3.0.tgz", + "integrity": "sha512-9ngPTOhYGQqNVSfeJkYXHmF7AGWp4/nN5D/QqNQs3Dvxd1Kk/WpjHfNujKHYUQ/5CoGyOyFNoWSPk5afzP0QVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "5.0.0" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/semantic-release/node_modules/cliui": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/semantic-release/node_modules/execa": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-9.6.1.tgz", + "integrity": "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/merge-streams": "^4.0.0", + "cross-spawn": "^7.0.6", + "figures": "^6.1.0", + "get-stream": "^9.0.0", + "human-signals": "^8.0.1", + "is-plain-obj": "^4.1.0", + "is-stream": "^4.0.1", + "npm-run-path": "^6.0.0", + "pretty-ms": "^9.2.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^4.0.0", + "yoctocolors": "^2.1.1" + }, + "engines": { + "node": "^18.19.0 || >=20.5.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/semantic-release/node_modules/execa/node_modules/get-stream": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-9.0.1.tgz", + "integrity": "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sec-ant/readable-stream": "^0.4.1", + "is-stream": "^4.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/semantic-release/node_modules/human-signals": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-8.0.1.tgz", + "integrity": "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/semantic-release/node_modules/indent-string": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz", + "integrity": "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/semantic-release/node_modules/is-stream": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", + "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/semantic-release/node_modules/npm-run-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz", + "integrity": "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^4.0.0", + "unicorn-magic": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/semantic-release/node_modules/p-reduce": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-reduce/-/p-reduce-3.0.0.tgz", + "integrity": "sha512-xsrIUgI0Kn6iyDYm9StOpOeK29XM1aboGji26+QEortiFST1hGZaUQOLhtEbqHErPpGW/aSz6allwK2qcptp0Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/semantic-release/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/semantic-release/node_modules/read-package-up": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/read-package-up/-/read-package-up-12.0.0.tgz", + "integrity": "sha512-Q5hMVBYur/eQNWDdbF4/Wqqr9Bjvtrw2kjGxxBbKLbx8bVCL8gcArjTy8zDUuLGQicftpMuU0riQNcAsbtOVsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up-simple": "^1.0.1", + "read-pkg": "^10.0.0", + "type-fest": "^5.2.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/semantic-release/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/semantic-release/node_modules/strip-final-newline": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-4.0.0.tgz", + "integrity": "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/semantic-release/node_modules/type-fest": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.7.0.tgz", + "integrity": "sha512-1URUxUqfHFM1c+zfSPsa3gnkO7Aq21qyH75SIduNYz4SzY964rn1X2vCMQaHSHhktiw+0kPa2iyb6PUpXqB6Vg==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/semantic-release/node_modules/unicorn-magic": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", + "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/semantic-release/node_modules/yargs": { + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", + "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^9.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "string-width": "^7.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^22.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/semantic-release/node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/semver-regex": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/semver-regex/-/semver-regex-4.0.5.tgz", + "integrity": "sha512-hunMQrEy1T6Jr2uEVjrAIqjwWcQTgOAcIM52C8MY1EZSD3DDNft04XzvYKPqjED65bNVVko0YI38nYeEHCX3yw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signale": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/signale/-/signale-1.4.0.tgz", + "integrity": "sha512-iuh+gPf28RkltuJC7W5MRi6XAjTDCAPC/prJUpQoG4vIP3MJZ+GTydVnodXA7pwvTKb2cA0m9OFZW/cdWy/I/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^2.3.2", + "figures": "^2.0.0", + "pkg-conf": "^2.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/signale/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/signale/node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/signale/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/signale/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "dev": true, + "license": "MIT" + }, + "node_modules/signale/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/signale/node_modules/figures": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-2.0.0.tgz", + "integrity": "sha512-Oa2M9atig69ZkfwiApY8F2Yy+tzMbazyvqv21R0NsSC8floSOC09BbT1ITWAdoMGQvJ/aZnR1KMwdx9tvHnTNA==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^1.0.5" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/signale/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/signale/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/skin-tone": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/skin-tone/-/skin-tone-2.0.0.tgz", + "integrity": "sha512-kUMbT1oBJCpgrnKoSr0o6wPtvRWT9W9UKvGLwfJYO2WuahZRHOpEyL1ckyMGgMWh0UdpmaoFqKKD29WTomNEGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "unicode-emoji-modifier-base": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/slice-ansi": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-5.0.0.tgz", + "integrity": "sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.0.0", + "is-fullwidth-code-point": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/spawn-error-forwarder": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/spawn-error-forwarder/-/spawn-error-forwarder-1.0.0.tgz", + "integrity": "sha512-gRjMgK5uFjbCvdibeGJuy3I5OYz6VLoVdsOJdA6wV0WlfQVLFueoqMxwwYD9RODdgb6oUIvlRlsyFSiQkMKu0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/spdx-correct": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", + "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "spdx-expression-parse": "^3.0.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-exceptions": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", + "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", + "dev": true, + "license": "CC-BY-3.0" + }, + "node_modules/spdx-expression-parse": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", + "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-license-ids": { + "version": "3.0.23", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.23.tgz", + "integrity": "sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/stream-combiner2": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/stream-combiner2/-/stream-combiner2-1.1.1.tgz", + "integrity": "sha512-3PnJbYgS56AeWgtKF5jtJRT6uFJe56Z0Hc5Ngg/6sI6rIt8iiMBTa9cvdyFfpMQjaVHr8dusbNeFGIIonxOvKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "duplexer2": "~0.1.0", + "readable-stream": "^2.0.2" + } + }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/string-argv": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", + "integrity": "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6.19" + } + }, + "node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/super-regex": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/super-regex/-/super-regex-1.1.0.tgz", + "integrity": "sha512-WHkws2ZflZe41zj6AolvvmaTrWds/VuyeYr9iPVv/oQeaIoVxMKaushfFWpOGDT+GuBrM/sVqF8KUCYQlSSTdQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-timeout": "^1.0.1", + "make-asynchronous": "^1.0.1", + "time-span": "^5.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-hyperlinks": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-3.2.0.tgz", + "integrity": "sha512-zFObLMyZeEwzAoKCyu1B91U79K2t7ApXuQfo8OuxwXLDgcKxuwM+YvcbIhm6QWqz7mHUH1TVytR1PwVVjEuMig==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0", + "supports-color": "^7.0.0" + }, + "engines": { + "node": ">=14.18" + }, + "funding": { + "url": "https://github.com/chalk/supports-hyperlinks?sponsor=1" + } + }, + "node_modules/tagged-tag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", + "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/temp-dir": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-3.0.0.tgz", + "integrity": "sha512-nHc6S/bwIilKHNRgK/3jlhDoIHcp45YgyiwcAk46Tr0LfEqGBVpmiAyuiuxeVE44m3mXnEeVhaipLOEWmH+Njw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + } + }, + "node_modules/tempy": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/tempy/-/tempy-3.2.0.tgz", + "integrity": "sha512-d79HhZya5Djd7am0q+W4RTsSU+D/aJzM+4Y4AGJGuGlgM2L6sx5ZvOYTmZjqPhrDrV6xJTtRSm1JCLj6V6LHLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-stream": "^3.0.0", + "temp-dir": "^3.0.0", + "type-fest": "^2.12.2", + "unique-string": "^3.0.0" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/tempy/node_modules/is-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", + "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/tempy/node_modules/type-fest": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", + "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/text-extensions": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/text-extensions/-/text-extensions-2.4.0.tgz", + "integrity": "sha512-te/NtwBwfiNRLf9Ijqx3T0nlqZiQ2XrrtBvu+cLL8ZRrGkO0NHTug8MYFKyoSrv/sHTaSKfilUkizV6XhxMJ3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/through": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", + "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/through2": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/through2/-/through2-2.0.5.tgz", + "integrity": "sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "readable-stream": "~2.3.6", + "xtend": "~4.0.1" + } + }, + "node_modules/time-span": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/time-span/-/time-span-5.1.0.tgz", + "integrity": "sha512-75voc/9G4rDIJleOo4jPvN4/YC4GRZrY8yy1uU4lwrB3XEQbWve8zXoO5No4eFrGcTAMYyoY67p8jRQdtA1HbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "convert-hrtime": "^5.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/traverse": { + "version": "0.6.8", + "resolved": "https://registry.npmjs.org/traverse/-/traverse-0.6.8.tgz", + "integrity": "sha512-aXJDbk6SnumuaZSANd21XAo15ucCDE38H4fkqiGsc3MhCK+wOlZvLP9cB/TvpHT0mOyWgC4Z8EwRlzqYSUzdsA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tunnel": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz", + "integrity": "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6.11 <=0.7.0 || >=0.7.3" + } + }, + "node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/uglify-js": { + "version": "3.19.3", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", + "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", + "dev": true, + "license": "BSD-2-Clause", + "optional": true, + "bin": { + "uglifyjs": "bin/uglifyjs" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/undici": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unicode-emoji-modifier-base": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unicode-emoji-modifier-base/-/unicode-emoji-modifier-base-1.0.0.tgz", + "integrity": "sha512-yLSH4py7oFH3oG/9K+XWrz1pSi3dfUrWEnInbxMfArOfc1+33BlGPQtLsOYwvdMy11AwUBetYuaRxSPqgkq+8g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/unicorn-magic": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.1.0.tgz", + "integrity": "sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/unique-string": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-3.0.0.tgz", + "integrity": "sha512-VGXBUVwxKMBUznyffQweQABPRRW1vHZAbadFZud4pLFAqRGvv/96vafgjWFqzourzr8YonlQiPgH0YCJfawoGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "crypto-random-string": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/universal-user-agent": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-7.0.3.tgz", + "integrity": "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==", + "dev": true, + "license": "ISC" + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/url-join": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/url-join/-/url-join-5.0.0.tgz", + "integrity": "sha512-n2huDr9h9yzd6exQVnH/jU5mr+Pfx08LRXXZhkLLetAMESRj+anQsTAh940iMrIetKAmry9coFuZQ2jY8/p3WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/validate-npm-package-license": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", + "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "spdx-correct": "^3.0.0", + "spdx-expression-parse": "^3.0.0" + } + }, + "node_modules/web-worker": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/web-worker/-/web-worker-1.5.0.tgz", + "integrity": "sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/yargs": { + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/yargs/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yocto-queue": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", + "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yoctocolors": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.2.tgz", + "integrity": "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/package.json b/package.json index 93f1e65..ba3a1c0 100644 --- a/package.json +++ b/package.json @@ -1,80 +1,67 @@ { - "name": "emulsify-wordpress-theme", - "version": "1.0.0-alpha.5", - "description": "Emulsify is a Design System that works with WordPress, Drupal, React, and more.", - "author": "Dean Oest <randy@fourkitchens.com>", + "name": "emulsify-wordpress", + "version": "2.0.0", + "description": "Timber-first WordPress parent theme that generates child themes with Emulsify Core 4, Vite, Storybook, and Twig", + "engines": { + "node": ">=24.10" + }, + "keywords": [ + "component library", + "design system", + "wordpress", + "pattern library", + "storybook", + "styleguide" + ], + "author": "Four Kitchens <shout@fourkitchens.com>", "license": "GPL-2.0-only", - "dependencies": { - "@babel/core": "^7.13.10", - "@babel/preset-env": "^7.13.10", - "@storybook/addon-a11y": "6.1.21", - "@storybook/addon-actions": "6.1.21", - "@storybook/addon-links": "6.1.21", - "@storybook/addon-viewport": "^6.1.21", - "@storybook/addons": "6.1.21", - "@storybook/react": "6.1.21", - "@storybook/storybook-deployer": "^2.8.7", - "add-attributes-twig-extension": "^0.1.0", - "autoprefixer": "^10.2.5", - "babel-eslint": "^10.0.3", - "babel-loader": "^8.2.2", - "babel-preset-minify": "^0.5.0", - "bem-twig-extension": "^0.1.1", - "breakpoint-sass": "^2.7.1", - "clean-webpack-plugin": "^3.0.0", - "concurrently": "^6.0.0", - "css-loader": "^5.1.3", - "eslint": "^7.22.0", - "eslint-config-airbnb": "^18.2.1", - "eslint-loader": "^4.0.2", - "eslint-plugin-import": "^2.22.1", - "eslint-plugin-jsx-a11y": "^6.4.1", - "eslint-plugin-react": "^7.22.0", - "file-loader": "^6.2.0", - "fs": "^0.0.1-security", - "glob": "^7.1.4", - "imagemin-webpack-plugin": "^2.4.2", - "js-yaml-loader": "^1.2.2", - "mini-css-extract-plugin": "^1.3.9", - "node-sass": "^5.0.0", - "node-sass-glob-importer": "^5.3.2", - "normalize.css": "^8.0.1", - "postcss": "^8.2.8", - "postcss-custom-properties": "^10.0.0", - "postcss-loader": "^4.1.0", - "ramda": "^0.27.1", - "react": "^17.0.1", - "react-dom": "^17.0.1", - "sass-loader": "^10.1.0", - "style-loader": "^2.0.0", - "stylelint": "^13.12.0", - "stylelint-config-prettier": "^8.0.2", - "stylelint-config-standard": "^21.0.0", - "stylelint-webpack-plugin": "^2.1.1", - "svg-sprite-loader": "^6.0.0", - "twig": "~1.15.4", - "twig-loader": "https://github.com/AmazeeLabs/twig-loader", - "webpack": "4.44.2", - "webpack-cli": "^4.5.0", - "webpack-merge": "^5.7.3" + "repository": { + "type": "git", + "url": "git+https://github.com/emulsify-ds/emulsify-wordpress.git" + }, + "bugs": { + "url": "https://github.com/emulsify-ds/emulsify-wordpress/issues" }, + "homepage": "https://www.emulsify.info", "scripts": { - "a11y": "npm run build-storybook && ./scripts/a11y.js -r", - "setup": "composer install && yarn", - "lint": "eslint ./components", - "storybook": "start-storybook --ci -s ./dist,./images -p 6006", - "build-storybook": "npm run build && build-storybook -s ./dist,./images -o .out", - "deploy-storybook": "storybook-to-ghpages -o .out", - "webpack": "webpack --watch --config ./webpack/webpack.dev.js", - "build": "webpack --config ./webpack/webpack.prod.js", - "develop": "concurrently --raw \"npm run webpack\" \"npm run storybook\"" + "husky:commit-msg": "commitlint --edit $1", + "husky:pre-commit": "npm run lint", + "lint": "npm run lint:php", + "lint:php": "vendor/bin/phpcs -q && vendor/bin/phpstan analyse --no-progress --memory-limit=1G", + "lint:php:fix": "vendor/bin/phpcbf", + "lint-staged": "lint-staged", + "prepare": "[ -d '.git' ] && (husky install) || true", + "pr:check": "node .github/scripts/pr-validation.cjs", + "release:check": "node .github/scripts/release-check.cjs", + "smoke:acf-json": "php .github/scripts/acf-local-json-smoke.php", + "smoke:asset-manifest": "php .github/scripts/asset-manifest-smoke.php", + "smoke:attributes": "php .github/scripts/attribute-helper-smoke.php", + "smoke:block-assets": "php .github/scripts/block-scoped-assets-smoke.php", + "smoke:bootstrap-loader": "php .github/scripts/bootstrap-loader-smoke.php", + "smoke:child-theme-generator": "php .github/scripts/child-theme-generator-smoke.php", + "smoke:component-locator": "php .github/scripts/component-locator-smoke.php", + "smoke:core-block-twig": "php .github/scripts/core-block-twig-renderer-smoke.php", + "smoke:editor-enhancements": "php .github/scripts/editor-enhancements-smoke.php", + "smoke:editor-policy": "php .github/scripts/editor-policy-smoke.php", + "smoke:patterns": "php .github/scripts/pattern-registry-smoke.php", + "smoke:starter-init": "node .github/scripts/wordpress-starter-init-smoke.cjs", + "smoke:theme-filters": "php .github/scripts/theme-filters-smoke.php", + "smoke:twig-switch": "php .github/scripts/twig-switch-smoke.php", + "smoke:twig-project-namespace": "php .github/scripts/twig-project-namespace-smoke.php", + "whisk:build": "npm --prefix whisk run build", + "whisk:install": "npm --prefix whisk install --package-lock=false --ignore-scripts", + "publish": "semantic-release --tag-format '${version}'", + "publish-test": "semantic-release --tag-format '${version}' --dry-run --debug", + "semantic-release": "semantic-release" }, "devDependencies": { - "core-js": "3.9.1", - "eslint-config-prettier": "^8.1.0", - "eslint-plugin-prettier": "^3.3.1", - "open-cli": "^6.0.1", - "pa11y": "^5.3.0", - "prettier": "2.2.1" + "@commitlint/cli": "^19.4.1", + "@commitlint/config-conventional": "^19.4.1", + "@semantic-release/commit-analyzer": "^13.0.1", + "@semantic-release/github": "^12.0.8", + "@semantic-release/release-notes-generator": "^14.1.1", + "husky": "^9.1.5", + "lint-staged": "^15.2.9", + "semantic-release": "^25.0.5" } } diff --git a/page.php b/page.php old mode 100755 new mode 100644 index 2918bd3..8304c9b --- a/page.php +++ b/page.php @@ -2,27 +2,17 @@ /** * The template for displaying all pages. * - * This is the template that displays all pages by default. - * Please note that this is the WordPress construct of pages - * and that other 'pages' on your WordPress site will use a - * different template. - * - * To generate specific templates for your pages you can use: - * /mytheme/templates/page-mypage.twig - * (which will still route through this PHP file) - * OR - * /mytheme/page-mypage.php - * (in which case you'll want to duplicate this file and save to the above path) - * - * Methods for TimberHelper can be found in the /lib sub-directory - * - * @package WordPress - * @subpackage Timber - * @since Timber 0.1 + * @package Emulsify */ +use Timber\Timber; + +if ( ! defined( 'ABSPATH' ) ) { + exit; +} + +// Page rendering is intentionally thin: route data comes from Timber context, +// while markup and extension points live in Twig. $context = Timber::context(); -$timber_post = new Timber\Post(); -$context['post'] = $timber_post; -Timber::render( array( 'page-' . $timber_post->post_name . '.twig', 'page.twig' ), $context ); +Timber::render( '@templates/page.twig', $context ); diff --git a/phpcs.xml.dist b/phpcs.xml.dist new file mode 100644 index 0000000..98e78d0 --- /dev/null +++ b/phpcs.xml.dist @@ -0,0 +1,65 @@ +<?xml version="1.0"?> +<ruleset name="Emulsify WordPress"> + <description>PHPCS rules for the Emulsify WordPress parent theme runtime.</description> + + <file>includes</file> + <file>404.php</file> + <file>archive.php</file> + <file>author.php</file> + <file>functions.php</file> + <file>index.php</file> + <file>page.php</file> + <file>search.php</file> + <file>single.php</file> + + <exclude-pattern>vendor/*</exclude-pattern> + <exclude-pattern>node_modules/*</exclude-pattern> + <exclude-pattern>whisk/*</exclude-pattern> + <exclude-pattern>.github/*</exclude-pattern> + + <arg name="parallel" value="8"/> + <arg value="ps"/> + + <config name="testVersion" value="8.3-"/> + + <rule ref="WordPress-Extra"/> + <rule ref="WordPress-Docs"/> + + <rule ref="WordPress.WP.I18n"> + <properties> + <property name="text_domain" type="array"> + <element value="emulsify"/> + </property> + </properties> + </rule> + + <!-- The 2.0 runtime uses Composer PSR-4 class files instead of legacy class-*.php names. --> + <rule ref="WordPress.Files.FileName"> + <exclude name="WordPress.Files.FileName"/> + </rule> + + <!-- The parent runtime intentionally reads local theme files and JSON manifests. --> + <rule ref="WordPress.WP.AlternativeFunctions"> + <exclude name="WordPress.WP.AlternativeFunctions"/> + </rule> + + <!-- Debug-only duplicate diagnostics are part of the parent runtime contract. --> + <rule ref="WordPress.PHP.DevelopmentFunctions"> + <exclude name="WordPress.PHP.DevelopmentFunctions"/> + </rule> + + <!-- Cache keys serialize trusted scalar arrays only when JSON encoding is unavailable. --> + <rule ref="WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize"> + <exclude name="WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize"/> + </rule> + + <!-- WordPress, Twig, and callback signatures occasionally use reserved parameter names. --> + <rule ref="Universal.NamingConventions.NoReservedKeywordParameterNames"> + <exclude name="Universal.NamingConventions.NoReservedKeywordParameterNames"/> + </rule> + + <!-- Some callback signatures intentionally retain unused positional parameters. --> + <rule ref="Generic.CodeAnalysis.UnusedFunctionParameter"> + <exclude name="Generic.CodeAnalysis.UnusedFunctionParameter"/> + </rule> +</ruleset> diff --git a/phpstan.neon.dist b/phpstan.neon.dist new file mode 100644 index 0000000..aa8dada --- /dev/null +++ b/phpstan.neon.dist @@ -0,0 +1,17 @@ +includes: + - vendor/szepeviktor/phpstan-wordpress/extension.neon + +parameters: + level: 5 + treatPhpDocTypesAsCertain: false + paths: + - includes/ + scanFiles: + - vendor/php-stubs/acf-pro-stubs/acf-pro-stubs.php + - vendor/php-stubs/wp-cli-stubs/wp-cli-stubs.php + parallel: + maximumNumberOfProcesses: 1 + excludePaths: + - vendor/ + - node_modules/ + - whisk/ diff --git a/phpunit.xml b/phpunit.xml deleted file mode 100644 index e7f21fc..0000000 --- a/phpunit.xml +++ /dev/null @@ -1,20 +0,0 @@ -<phpunit - bootstrap="tests/bootstrap.php" - backupGlobals="false" - colors="true" - convertErrorsToExceptions="true" - convertNoticesToExceptions="true" - convertWarningsToExceptions="true" - > - <testsuites> - <testsuite> - <directory prefix="test-" suffix=".php">./tests/</directory> - </testsuite> - <!-- The suite below HAS to be last to run, - as it includes a test that sets some const and would contaminate - the other tests as well. --> - <testsuite> - <directory prefix="testX-" suffix=".php">./tests/</directory> - </testsuite> - </testsuites> -</phpunit> diff --git a/postcss.config.js b/postcss.config.js deleted file mode 100644 index 3e11de2..0000000 --- a/postcss.config.js +++ /dev/null @@ -1,7 +0,0 @@ -// postcssCustomProperties only needed for IE11 - remove if unnecessary for your project. -const postcssCustomProperties = require('postcss-custom-properties'); -const autoPrefixer = require('autoprefixer'); - -module.exports = { - plugins: [postcssCustomProperties(), autoPrefixer()], -}; diff --git a/prettier.config.js b/prettier.config.js deleted file mode 100644 index de2f53c..0000000 --- a/prettier.config.js +++ /dev/null @@ -1,4 +0,0 @@ -module.exports = { - singleQuote: true, - trailingComma: 'all', -}; diff --git a/release.config.js b/release.config.js new file mode 100644 index 0000000..ffc4192 --- /dev/null +++ b/release.config.js @@ -0,0 +1,91 @@ +const parserOpts = { + // semantic-release's default parser does not treat "feat!:" consistently + // across the release lines this project supports, so keep the breaking-change + // header patterns explicit. + headerPattern: /^(\w*)(?:\(([\w$.\-*/ ]*)\))?!?: (.*)$/, + headerCorrespondence: ['type', 'scope', 'subject'], + breakingHeaderPattern: /^(\w*)(?:\(([\w$.\-*/ ]*)\))?!: (.*)$/, + noteKeywords: ['BREAKING CHANGE', 'BREAKING CHANGES', 'BREAKING-CHANGE'], +}; + +const expectedStableRelease = '2.0.0'; + +function parseVersionParts(version) { + return String(version || '') + .split('-')[0] + .split('.') + .map((part) => Number.parseInt(part, 10)); +} + +function isAtLeastVersion(version, minimumVersion) { + const current = parseVersionParts(version); + const minimum = parseVersionParts(minimumVersion); + + if ( + current.length !== 3 || + minimum.length !== 3 || + current.some(Number.isNaN) || + minimum.some(Number.isNaN) + ) { + return false; + } + + for (let index = 0; index < current.length; index += 1) { + if (current[index] > minimum[index]) { + return true; + } + + if (current[index] < minimum[index]) { + return false; + } + } + + return true; +} + +function stableVersionBase(version) { + return String(version || '').split('-')[0]; +} + +const expectedStableReleaseGuard = { + verifyRelease(pluginConfig, { branch, lastRelease = {}, nextRelease }) { + // The 2.x release branch prepares the stable 2.0.0 baseline. After that + // baseline exists, normal semantic-release versioning can continue. + if (branch.name !== 'main' || isAtLeastVersion(lastRelease.version, expectedStableRelease)) { + return; + } + + if (nextRelease.version !== expectedStableRelease) { + const message = `Expected semantic-release to prepare ${expectedStableRelease} on main, but computed ${nextRelease.version}. Confirm stable baseline tags and breaking-change commits before publishing.`; + throw new Error(message); + } + }, +}; + +const expectedStableReleaseAnalyzer = { + analyzeCommits(pluginConfig, { branch, lastRelease = {} }) { + // The release-2.x branch is the first stable release line for the rebuilt + // parent theme. The latest existing tag is an alpha, so normalize it to its + // stable base before semantic-release increments it to 2.0.0. + if (branch.name === 'main' && !isAtLeastVersion(lastRelease.version, expectedStableRelease)) { + lastRelease.version = stableVersionBase(lastRelease.version); + return 'major'; + } + + return null; + }, +}; + +module.exports = { + expectedStableRelease, + tagFormat: '${version}', + branches: ['main'], + repositoryUrl: 'git@github.com:emulsify-ds/emulsify-wordpress.git', + plugins: [ + expectedStableReleaseAnalyzer, + ['@semantic-release/commit-analyzer', { parserOpts }], + ['@semantic-release/release-notes-generator', { parserOpts }], + expectedStableReleaseGuard, + '@semantic-release/github', + ], +}; diff --git a/screenshot.png b/screenshot.png index dce03e0..43e603e 100644 Binary files a/screenshot.png and b/screenshot.png differ diff --git a/scripts/a11y.js b/scripts/a11y.js deleted file mode 100755 index 5af737b..0000000 --- a/scripts/a11y.js +++ /dev/null @@ -1,89 +0,0 @@ -#!/usr/bin/env node -/** - * @file a11y.js - * Contains a script that, when executed, will execute a11y linting tools - * against the storybook build. - */ - -const R = require('ramda'); -const path = require('path'); -const pa11y = require('pa11y'); -const chalk = require('chalk'); -const { - storybookBuildDir, - pa11y: pa11yConfig, - ignore, - components, -} = require('../a11y.config.js'); - -const STORYBOOK_BUILD_DIR = path.resolve(__dirname, '../', storybookBuildDir); -const STORYBOOK_IFRAME = path.join(STORYBOOK_BUILD_DIR, 'iframe.html'); - -const severityToColor = R.cond([ - [R.equals('error'), R.always('red')], - [R.equals('warning'), R.always('yellow')], - [R.equals('notice'), R.always('blue')], -]); - -const issueIsValid = ({ code, runnerExtras: { description } }) => - ignore.codes.includes(code) || ignore.descriptions.includes(description) - ? false - : true; - -const logIssue = ({ type: severity, message, context, selector }) => { - console.log(` - severity: ${chalk[severityToColor(severity)](severity)} - message: ${message} - context: ${context} - selector: ${selector} - `); -}; - -const logReport = ({ issues, pageUrl }) => { - const validIssues = issues.filter(issueIsValid); - const hasIssues = validIssues.length > 0; - - if (hasIssues) { - console.log(chalk.red(`Issues found in component: ${pageUrl}`)); - validIssues.map(logIssue); - } else { - console.log(chalk.green(`No issues found in component: ${pageUrl}`)); - } - - return hasIssues; -}; - -const lintComponent = async (name) => - pa11y(`${STORYBOOK_IFRAME}?id=${name}`, { - includeNotices: true, - includeWarnings: true, - runners: ['axe'], - ...pa11yConfig, - }); - -const lintReportAndExit = R.pipe( - R.map(lintComponent), - (p) => Promise.all(p), - R.andThen( - R.pipe( - R.map(logReport), - R.reject(R.equals(false)), - R.unless(R.isEmpty, () => process.exit(1)), - ), - ), -); - -// Only perform linting/reporting when instructed. -/* istanbul ignore next */ -if (R.pathEq(['argv', 2], '-r')(process)) { - lintReportAndExit(components); -} - -module.exports = { - severityToColor, - issueIsValid, - logIssue, - logReport, - lintComponent, - lintReportAndExit, -}; diff --git a/scripts/a11y.test.js b/scripts/a11y.test.js deleted file mode 100644 index fa20520..0000000 --- a/scripts/a11y.test.js +++ /dev/null @@ -1,157 +0,0 @@ -const mockExit = jest - .spyOn(global.process, 'exit') - .mockImplementation(() => {}); -jest.mock('pa11y', () => jest.fn()); -jest.spyOn(global.console, 'log').mockImplementation(() => {}); -const pa11y = require('pa11y'); -const path = require('path'); -const { - severityToColor, - issueIsValid, - logIssue, - logReport, - lintComponent, - lintReportAndExit, -} = require('./a11y'); -const { - ignore, - storybookBuildDir, - pa11y: pa11yConfig, -} = require('../a11y.config.js'); - -const STORYBOOK_BUILD_DIR = path.resolve(__dirname, '../', storybookBuildDir); -const STORYBOOK_IFRAME = path.join(STORYBOOK_BUILD_DIR, 'iframe.html'); - -pa11y.mockResolvedValue('very official report'); - -describe('a11y', () => { - beforeEach(() => { - global.console.log.mockClear(); - global.process.exit.mockClear(); - }); - it('can map axe issue severity to the correct chalk color', () => { - expect.assertions(3); - expect(severityToColor('error')).toBe('red'); - expect(severityToColor('warning')).toBe('yellow'); - expect(severityToColor('notice')).toBe('blue'); - }); - - it('identifies invalid issues based on the code or the description', () => { - expect.assertions(3); - expect( - issueIsValid({ - code: ignore.codes[0], - runnerExtras: {}, - }), - ).toBe(false); - expect( - issueIsValid({ - runnerExtras: { - description: ignore.descriptions[0], - }, - }), - ).toBe(false); - expect(issueIsValid({ code: 'chicken', runnerExtras: {} })).toBe(true); - }); - - it('can use an axe issue to generate a single log message about the issue', () => { - expect.assertions(1); - logIssue({ - type: 'error', - message: 'this chicken is not fried enough.', - context: 'https://example.com', - selector: 'kfc > popeyes > .chicken', - }); - expect(global.console.log.mock.calls[0][0]).toMatchInlineSnapshot(` - " - severity: error - message: this chicken is not fried enough. - context: https://example.com - selector: kfc > popeyes > .chicken - " - `); - }); - - it('can log a whole axe report', () => { - const report = { - issues: [ - { - type: 'error', - message: 'this pizza is too soggy', - context: 'https://example.com', - selector: 'pizza > .hut', - runnerExtras: {}, - }, - { - type: 'error', - message: 'this pasta is undercooked', - context: 'https://example.com', - selector: 'olive > .garden', - runnerExtras: {}, - }, - ], - pageUrl: 'https://example/component.html', - }; - expect(logReport(report)).toBe(true); - expect(global.console.log.mock.calls).toMatchInlineSnapshot(` - Array [ - Array [ - "Issues found in component: https://example/component.html", - ], - Array [ - " - severity: error - message: this pizza is too soggy - context: https://example.com - selector: pizza > .hut - ", - ], - Array [ - " - severity: error - message: this pasta is undercooked - context: https://example.com - selector: olive > .garden - ", - ], - ] - `); - }); - - it('logs about a component having no issue if a report comes back empty', () => { - expect(logReport({ issues: [], pageUrl: 'papa-johns' })).toBe(false); - expect(global.console.log.mock.calls[0][0]).toMatchInlineSnapshot( - `"No issues found in component: papa-johns"`, - ); - }); - - it('can call pa11y with the full path to a component', async () => { - expect.assertions(2); - await expect(lintComponent('chicken-strips')).resolves.toBe( - 'very official report', - ); - expect(pa11y).toHaveBeenCalledWith( - `${STORYBOOK_IFRAME}?id=chicken-strips`, - pa11yConfig, - ); - }); - - it('runs linter, reports on issues, and exits with code "1" if valid issues are found', async () => { - expect.assertions(1); - pa11y.mockResolvedValueOnce({ - issues: [ - { - type: 'error', - message: 'these 7 layer supreme burritos do not taste that good', - context: 'https://example.com', - selector: 'taco > bell > .burrito', - runnerExtras: {}, - }, - ], - pageUrl: '/path/to/taco-bell', - }); - - await lintReportAndExit(['taco-bell']); - expect(global.process.exit).toHaveBeenCalledWith(1); - }); -}); diff --git a/search.php b/search.php old mode 100755 new mode 100644 index 600a6ab..67306e1 --- a/search.php +++ b/search.php @@ -1,18 +1,23 @@ <?php /** - * Search results page + * Search results template. * - * Methods for TimberHelper can be found in the /lib sub-directory - * - * @package WordPress - * @subpackage Timber - * @since Timber 0.1 + * @package Emulsify */ -$templates = array( 'search.twig', 'archive.twig', 'index.twig' ); +use Timber\Timber; + +if ( ! defined( 'ABSPATH' ) ) { + exit; +} + +$templates = array( '@templates/search.twig', '@templates/archive.twig', '@templates/index.twig' ); -$context = Timber::context(); -$context['title'] = 'Search results for ' . get_search_query(); -$context['posts'] = new Timber\PostQuery(); +$context = Timber::context( + array( + /* translators: %s is the search query. */ + 'title' => sprintf( __( 'Search results for %s', 'emulsify' ), get_search_query() ), + ) +); Timber::render( $templates, $context ); diff --git a/sidebar.php b/sidebar.php deleted file mode 100644 index 63a3492..0000000 --- a/sidebar.php +++ /dev/null @@ -1,9 +0,0 @@ -<?php -/** - * The Template for the sidebar containing the main widget area - * - * @package WordPress - * @subpackage Timber - */ - -Timber::render( array( 'sidebar.twig' ), $data ); diff --git a/single.php b/single.php old mode 100755 new mode 100644 index 95d1d47..a9fbc3f --- a/single.php +++ b/single.php @@ -1,20 +1,29 @@ <?php /** - * The Template for displaying all single posts + * The Template for displaying all single posts. * - * Methods for TimberHelper can be found in the /lib sub-directory - * - * @package WordPress - * @subpackage Timber - * @since Timber 0.1 + * @package Emulsify */ -$context = Timber::context(); -$timber_post = Timber::query_post(); -$context['post'] = $timber_post; +use Timber\Timber; + +if ( ! defined( 'ABSPATH' ) ) { + exit; +} + +$context = Timber::context(); +$wp_post = $context['post'] ?? null; +$post_type_slug = $wp_post->post_type ?? get_post_type(); +$templates = array( '@templates/single.twig' ); -if ( post_password_required( $timber_post->ID ) ) { - Timber::render( 'single-password.twig', $context ); -} else { - Timber::render( array( 'single-' . $timber_post->ID . '.twig', 'single-' . $timber_post->post_type . '.twig', 'single-' . $timber_post->slug . '.twig', 'single.twig' ), $context ); +if ( $post_type_slug ) { + // Child themes can add single-{post_type}.twig to override one post type + // without duplicating the generic single.twig fallback. + array_unshift( $templates, '@templates/single-' . $post_type_slug . '.twig' ); } + +if ( $wp_post && post_password_required( $wp_post->ID ) ) { + $templates = '@templates/single-password.twig'; +} + +Timber::render( $templates, $context ); diff --git a/src/components/.gitkeep b/src/components/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/static/no-timber.html b/static/no-timber.html deleted file mode 100644 index b99c04b..0000000 --- a/static/no-timber.html +++ /dev/null @@ -1,10 +0,0 @@ -<!doctype html> -<html lang="en"> - <head> - <title>Timber not active - - - -

    Timber not activated

    - - diff --git a/static/site.js b/static/site.js deleted file mode 100755 index 6655bd0..0000000 --- a/static/site.js +++ /dev/null @@ -1,5 +0,0 @@ -jQuery( document ).ready( function( $ ) { - - // Your JavaScript goes here - -}); \ No newline at end of file diff --git a/style.css b/style.css index 35ae8b9..ca4b220 100644 --- a/style.css +++ b/style.css @@ -1,15 +1,12 @@ /* -Theme Name: Emulsify WordPress Theme -Theme URI: https://github.com/emulsify-ds/emulsify-wordpress-theme -Author: Four Kitchens -Author URI: https://www.emulsify.info/ -Description: Starter theme for Emulsify in WordPress. -Version: 1.0 -License: GNU General Public License v2 or later -License URI: http://www.gnu.org/licenses/gpl-2.0.html -Tags: emulsify, design system, components -Text Domain: emulsify - -This theme, like WordPress, is licensed under the GPL. -Use it to make something cool, have fun, and share what you've learned with others. + * Theme Name: Emulsify + * Author: Callin Mullaney & Mike Goulding + * Description: Timber-first WordPress parent theme for generated child themes with Emulsify Core 4, Vite, Storybook, and Twig. + * Text Domain: emulsify + * Requires at least: 6.7 + * Tested up to: 6.7 + * Requires PHP: 8.3 + * Version: 2.0.0 + * License: GPL-2.0-only + * License URI: https://www.gnu.org/licenses/old-licenses/gpl-2.0.html */ diff --git a/templates/404.twig b/templates/404.twig index 9105453..7f1e1fc 100644 --- a/templates/404.twig +++ b/templates/404.twig @@ -1,5 +1,20 @@ -{% extends "@templates/_default.twig" %} +{# Generic 404 fallback rendered by 404.php. Child themes can override this file + without changing PHP route logic. #} +{% extends '@templates/layouts/base.twig' %} + +{% set not_found_attributes = { + class: bem('not-found') +} %} +{% set not_found_title_attributes = { + class: bem('title', [], 'not-found') +} %} +{% set not_found_message_attributes = { + class: bem('message', [], 'not-found') +} %} {% block content %} - Sorry, we couldn't find what you're looking for. +
    +

    {{ function('__', 'Page not found', 'emulsify') }}

    +

    {{ function('__', 'The requested page could not be found.', 'emulsify') }}

    +
    {% endblock %} diff --git a/templates/archive.twig b/templates/archive.twig index 7e79671..7e7f1cb 100644 --- a/templates/archive.twig +++ b/templates/archive.twig @@ -1,9 +1,33 @@ -{# This file demonstrates using most of the index.twig template and modifying - just a small part. See `search.twig` for an example of another approach #} +{# Generic archive fallback. PHP route files set the page title; this template + only decides how archive entries and pagination render. #} +{% extends '@templates/layouts/base.twig' %} -{% extends "index.twig" %} +{% set entry_list_attributes = { + class: bem('entry-list') +} %} {% block content %} -

    This is my archive

    - {{ parent() }} + {% set entries = posts|default([]) %} + + {% if entries is not empty %} +
    + {% for post in entries %} + {# Prefer a post-type-specific teaser partial, then fall back to + the generic teaser so child themes can override narrowly. #} + {% include ['@templates/partials/tease-' ~ post.type ~ '.twig', '@templates/partials/tease.twig'] with { + post: post + } only %} + {% endfor %} +
    + + {% include '@templates/partials/pagination.twig' with { + pagination: posts.pagination({ + show_all: false, + mid_size: 3, + end_size: 2 + }) + } only %} + {% else %} +

    {{ function('__', 'No posts were found.', 'emulsify') }}

    + {% endif %} {% endblock %} diff --git a/templates/author.twig b/templates/author.twig index 9b35dc9..635f51d 100644 --- a/templates/author.twig +++ b/templates/author.twig @@ -1,7 +1,24 @@ -{% extends "@templates/_default.twig" %} +{# Author archive fallback. The author route controller sets the title and this + template reuses the same teaser pattern as other archives. #} +{% extends '@templates/layouts/base.twig' %} + +{% set entry_list_attributes = { + class: bem('entry-list') +} %} {% block content %} - {% for post in posts %} - {% include ["tease-"~post.post_type~".twig", "tease.twig"] %} - {% endfor %} + {% set entries = posts|default([]) %} + + {% if entries is not empty %} +
    + {% for post in entries %} + {# Use the same post-type teaser fallback chain as archive.twig. #} + {% include ['@templates/partials/tease-' ~ post.type ~ '.twig', '@templates/partials/tease.twig'] with { + post: post + } only %} + {% endfor %} +
    + {% else %} +

    {{ function('__', 'No posts were found for this author.', 'emulsify') }}

    + {% endif %} {% endblock %} diff --git a/templates/comment-form.twig b/templates/comment-form.twig deleted file mode 100644 index b2b0a2e..0000000 --- a/templates/comment-form.twig +++ /dev/null @@ -1,28 +0,0 @@ -
    -

    Add comment

    -
    - {% if user %} - - - - {% else %} - - - - {% endif %} - - - - - -

    Your comment will be revised by the site if needed.

    -
    -
    diff --git a/templates/comment.twig b/templates/comment.twig deleted file mode 100644 index 40ce11c..0000000 --- a/templates/comment.twig +++ /dev/null @@ -1,21 +0,0 @@ -
    -
    {{comment.author.name}} says
    -
    {{comment.comment_content|wpautop}}
    - -
    - - - {% include "comment-form.twig" %} - - - {% if post.comments %} -

    replies

    -
    - {% for cmt in comment.children %} - {% include "comment.twig" with {comment:cmt} %} - {% endfor %} -
    - {% endif %} - -
    -
    \ No newline at end of file diff --git a/templates/index.twig b/templates/index.twig index 568041e..3ae4ce8 100644 --- a/templates/index.twig +++ b/templates/index.twig @@ -1,9 +1,32 @@ -{% extends "@templates/_default.twig" %} +{# Last-resort post listing fallback. More specific route controllers can pass + front-page/home/search/archive templates before this one. #} +{% extends '@templates/layouts/base.twig' %} + +{% set entry_list_attributes = { + class: bem('entry-list') +} %} {% block content %} - {% for post in posts %} - {% include ['tease-'~post.post_type~'.twig', 'tease.twig'] %} - {% endfor %} - - {% include 'partial/pagination.twig' with { pagination: posts.pagination({show_all: false, mid_size: 3, end_size: 2}) } %} + {% set entries = posts|default([]) %} + + {% if entries is not empty %} +
    + {% for post in entries %} + {# Prefer a post-type-specific teaser partial before generic teaser markup. #} + {% include ['@templates/partials/tease-' ~ post.type ~ '.twig', '@templates/partials/tease.twig'] with { + post: post + } only %} + {% endfor %} +
    + + {% include '@templates/partials/pagination.twig' with { + pagination: posts.pagination({ + show_all: false, + mid_size: 3, + end_size: 2 + }) + } only %} + {% else %} +

    {{ function('__', 'No posts were found.', 'emulsify') }}

    + {% endif %} {% endblock %} diff --git a/templates/layouts/base.twig b/templates/layouts/base.twig new file mode 100644 index 0000000..6851b1b --- /dev/null +++ b/templates/layouts/base.twig @@ -0,0 +1,84 @@ +{# Shared document shell. Keep structural WordPress hooks here so child page + templates can focus on content blocks and component composition. #} +{% set body_attributes = { + class: body_class|default('') +} %} +{% set skip_link_attributes = { + class: ['skip-link', 'screen-reader-text'], + href: '#content' +} %} +{% set site_header_attributes = { + class: bem('site-header'), + role: 'banner' +} %} +{% set site_header_inner_attributes = { + class: bem('inner', [], 'site-header') +} %} +{% set site_header_brand_attributes = { + class: bem('brand', [], 'site-header'), + href: site.url|default('/'), + rel: 'home' +} %} +{% set site_header_navigation_attributes = { + class: bem('navigation', [], 'site-header'), + 'aria-label': function('__', 'Primary navigation', 'emulsify') +} %} +{% set site_main_attributes = { + class: bem('site-main'), + id: 'content', + tabindex: '-1' +} %} +{% set page_header_attributes = { + class: bem('page-header') +} %} +{% set page_header_title_attributes = { + class: bem('title', [], 'page-header') +} %} +{% set menu_items = menu|default(null) ? menu.get_items : [] %} + + + {% block head %} + {% include '@templates/partials/head.twig' %} + {% endblock %} + + + {{ function('wp_body_open') }} + {{ function('__', 'Skip to content', 'emulsify') }} + +
    +
    + {% block header %} + + {{ site.name|default('') }} + + + {% if menu_items is not empty %} + + {% endif %} + {% endblock %} +
    +
    + +
    + {% if title|default(null) %} +
    +

    {{ title }}

    +
    + {% endif %} + + {% block content %}{% endblock %} +
    + + {% block footer %} + {% include '@templates/partials/footer.twig' %} + {% endblock %} + + {{ function('wp_footer') }} + {% do action('get_footer') %} + + diff --git a/templates/menu.twig b/templates/menu.twig deleted file mode 100644 index 0650f61..0000000 --- a/templates/menu.twig +++ /dev/null @@ -1,10 +0,0 @@ -{% if menu %} -
      - {% for item in items %} -
    • - {{ item.title }} - {% include "menu.twig" with {'items': item.children} %} -
    • - {% endfor %} -
    -{% endif %} diff --git a/templates/page-plugin.twig b/templates/page-plugin.twig deleted file mode 100644 index 913b0f5..0000000 --- a/templates/page-plugin.twig +++ /dev/null @@ -1,7 +0,0 @@ -{% extends "@templates/_default.twig" %} - -{% block content %} -
    - {{content}} -
    -{% endblock %} diff --git a/templates/page.twig b/templates/page.twig index 3278c21..08a14bc 100644 --- a/templates/page.twig +++ b/templates/page.twig @@ -1,14 +1,33 @@ -{% extends "@templates/_default.twig" %} +{# Page fallback rendered by page.php. Child themes should extend or override + this file instead of copying PHP route controllers. #} +{% extends '@templates/layouts/base.twig' %} + +{% set entry_attributes = { + class: bem('entry', [post.type|default('page')]), + id: post.id|default(null) ? 'post-' ~ post.id : null +} %} +{% set entry_header_attributes = { + class: bem('header', [], 'entry') +} %} +{% set entry_title_attributes = { + class: bem('title', [], 'entry') +} %} +{% set entry_content_attributes = { + class: bem('content', [], 'entry') +} %} {% block content %} -
    -
    -
    -

    {{post.title}}

    -
    - {{post.content}} -
    -
    -
    -
    + {% if post|default(null) %} +
    +
    +

    {{ post.title }}

    +
    + +
    + {{ post.content }} +
    +
    + {% else %} +

    {{ function('__', 'No page content was found.', 'emulsify') }}

    + {% endif %} {% endblock %} diff --git a/templates/partial/pagination.twig b/templates/partial/pagination.twig deleted file mode 100644 index fb7ccf8..0000000 --- a/templates/partial/pagination.twig +++ /dev/null @@ -1 +0,0 @@ -{% include "@molecules/pager/pager.twig" %} diff --git a/templates/partials/comment-form.twig b/templates/partials/comment-form.twig new file mode 100644 index 0000000..f828be0 --- /dev/null +++ b/templates/partials/comment-form.twig @@ -0,0 +1,61 @@ +{# Minimal comment form fallback. Projects that need full core comment_form() + behavior should override this partial in the child theme. #} +{% set comment_form_attributes = { + class: bem('comment-form') +} %} +{% set comment_form_title_attributes = { + class: bem('title', [], 'comment-form') +} %} +{% set comment_form_form_attributes = { + class: bem('form', [], 'comment-form'), + method: 'post', + action: site.link|default('') ~ '/wp-comments-post.php' +} %} +{% set comment_form_field_attributes = { + class: bem('field', [], 'comment-form') +} %} +{% set comment_form_submit_attributes = { + class: bem('submit', [], 'comment-form'), + type: 'submit', + name: 'Submit' +} %} + +{% if post|default(null) and post.id|default(null) %} + {% set current_user = user|default(null) %} + +
    +

    {{ function('__', 'Leave a comment', 'emulsify') }}

    + +
    + {% if current_user %} + {# Logged-in users already have identity fields in WordPress; keep + hidden values for compatibility with wp-comments-post.php. #} + + + + {% else %} +

    + + +

    +

    + + +

    +

    + + +

    + {% endif %} + +

    + + +

    + + + + +
    +
    +{% endif %} diff --git a/templates/partials/comment.twig b/templates/partials/comment.twig new file mode 100644 index 0000000..3a1e9f4 --- /dev/null +++ b/templates/partials/comment.twig @@ -0,0 +1,44 @@ +{# Recursive comment partial. It renders a comment and then reuses itself for + any nested children supplied by Timber. #} +{% set comment_attributes = { + class: bem('comment'), + id: 'comment-' ~ (comment.id|default('')) +} %} +{% set comment_header_attributes = { + class: bem('header', [], 'comment') +} %} +{% set comment_author_attributes = { + class: bem('author', [], 'comment') +} %} +{% set comment_content_attributes = { + class: bem('content', [], 'comment') +} %} +{% set comment_children_attributes = { + class: bem('children', [], 'comment') +} %} + +{% if comment|default(null) %} +
    + {% if comment.author.name|default(null) %} +
    +

    {{ comment.author.name }}

    +
    + {% endif %} + + {% if comment.content|default(null) %} +
    {{ comment.content|wpautop }}
    + {% endif %} + + {% set children = comment.children|default([]) %} + {% if children is not empty %} +
    + {% for child in children %} + {# Re-enter this same partial for threaded comments. #} + {% include '@templates/partials/comment.twig' with { + comment: child + } only %} + {% endfor %} +
    + {% endif %} +
    +{% endif %} diff --git a/templates/partials/footer.twig b/templates/partials/footer.twig new file mode 100644 index 0000000..4158fe2 --- /dev/null +++ b/templates/partials/footer.twig @@ -0,0 +1,15 @@ +{# Shared footer fallback. Child themes normally override this partial rather + than editing the base layout. #} +{% set site_footer_attributes = { + class: bem('site-footer'), + role: 'contentinfo' +} %} +{% set site_footer_copyright_attributes = { + class: bem('copyright', [], 'site-footer') +} %} + +
    +

    + © {{ 'now'|date('Y') }} {{ site.name|default('') }} +

    +
    diff --git a/templates/partials/head.twig b/templates/partials/head.twig new file mode 100644 index 0000000..d67e387 --- /dev/null +++ b/templates/partials/head.twig @@ -0,0 +1,8 @@ +{# Document head fallback. Keep wp_head() here so plugin, block, and WordPress + assets continue to enqueue through normal core hooks. #} + + + + {% do action('get_header') %} + {{ function('wp_head') }} + diff --git a/templates/partials/menu.twig b/templates/partials/menu.twig new file mode 100644 index 0000000..baa0faa --- /dev/null +++ b/templates/partials/menu.twig @@ -0,0 +1,37 @@ +{# Recursive menu partial for Timber menu item trees. #} +{% set menu_items = items|default([]) %} +{% set menu_attributes = { + class: menu_class|default('menu') +} %} +{% if menu_items is not empty %} +
      + {% for item in menu_items %} + {% set children = item.children|default([]) %} + {% set item_attributes = { + class: [ + bem('item', [children is not empty ? 'has-children' : ''], 'menu'), + item.classes|default([]) + ] + } %} + {% set link_attributes = { + class: bem('link', [], 'menu'), + href: item.link|default('#'), + target: item.target|default(null), + rel: item.target|default(null) ? 'noopener' : null + } %} +
    • + + {{ item.title|default('') }} + + + {% if children is not empty %} + {# Render submenu items with the same markup contract. #} + {% include '@templates/partials/menu.twig' with { + items: children, + menu_class: 'menu menu--sub' + } only %} + {% endif %} +
    • + {% endfor %} +
    +{% endif %} diff --git a/templates/partials/pagination.twig b/templates/partials/pagination.twig new file mode 100644 index 0000000..e3927ec --- /dev/null +++ b/templates/partials/pagination.twig @@ -0,0 +1,71 @@ +{# Timber pagination fallback. Route templates pass a pagination object created + from posts.pagination(). #} +{% set pages = pagination.pages|default([]) %} +{% set pagination_attributes = { + class: bem('pagination'), + 'aria-label': function('__', 'Pagination', 'emulsify') +} %} +{% set pagination_items_attributes = { + class: bem('items', [], 'pagination') +} %} +{% if pages is not empty %} + +{% endif %} diff --git a/templates/partials/tease-post.twig b/templates/partials/tease-post.twig new file mode 100644 index 0000000..4a88c99 --- /dev/null +++ b/templates/partials/tease-post.twig @@ -0,0 +1,20 @@ +{# Post-specific teaser extension. Other post types can add tease-{type}.twig + without duplicating the generic teaser partial. #} +{% extends '@templates/partials/tease.twig' %} + +{% set teaser_summary_attributes = { + class: bem('summary', [], 'teaser') +} %} + +{% block summary %} + {% if post.excerpt|default(null) %} +
    + {{ + post.excerpt({ + words: 20, + read_more: function('__', 'Continue reading', 'emulsify') + }) + }} +
    + {% endif %} +{% endblock %} diff --git a/templates/partials/tease.twig b/templates/partials/tease.twig new file mode 100644 index 0000000..717e2e8 --- /dev/null +++ b/templates/partials/tease.twig @@ -0,0 +1,38 @@ +{# Generic teaser card used by index/archive/search fallbacks. #} +{% set teaser_attributes = { + class: bem('teaser', [post.type|default('post')]), + id: post.id|default(null) ? 'teaser-' ~ post.id : null +} %} +{% set teaser_media_attributes = { + class: bem('media', [], 'teaser') +} %} +{% set teaser_title_attributes = { + class: bem('title', [], 'teaser') +} %} +{% set teaser_summary_attributes = { + class: bem('summary', [], 'teaser') +} %} + +{% if post|default(null) %} +
    + {% block media %} + {% if post.thumbnail|default(null) and post.thumbnail.src|default(null) %} +
    + {{ post.thumbnail.alt|default(post.title) }} +
    + {% endif %} + {% endblock %} + + {% block title %} +

    + {{ post.title|default(function('__', 'Untitled', 'emulsify')) }} +

    + {% endblock %} + + {% block summary %} + {% if post.excerpt|default(null) %} +
    {{ post.excerpt }}
    + {% endif %} + {% endblock %} +
    +{% endif %} diff --git a/templates/search.twig b/templates/search.twig index 220f905..a350a2b 100644 --- a/templates/search.twig +++ b/templates/search.twig @@ -1,13 +1,31 @@ -{# see `archive.twig` for an alternative strategy of extending templates #} -{% extends "@templates/_default.twig" %} +{# Search results fallback. search.php sets the translated search title. #} +{% extends '@templates/layouts/base.twig' %} + +{% set entry_list_attributes = { + class: bem('entry-list') +} %} {% block content %} - {# see `base.twig:27` for where this block's content will be inserted #} -
    - {% for post in posts %} - {% include ['tease-'~post.post_type~'.twig', 'tease.twig'] %} - {% endfor %} - - {% include 'partial/pagination.twig' with { pagination: posts.pagination({show_all: false, mid_size: 3, end_size: 2}) } %} -
    + {% set entries = posts|default([]) %} + + {% if entries is not empty %} +
    + {% for post in entries %} + {# Reuse archive teaser fallbacks for each result type. #} + {% include ['@templates/partials/tease-' ~ post.type ~ '.twig', '@templates/partials/tease.twig'] with { + post: post + } only %} + {% endfor %} +
    + + {% include '@templates/partials/pagination.twig' with { + pagination: posts.pagination({ + show_all: false, + mid_size: 3, + end_size: 2 + }) + } only %} + {% else %} +

    {{ function('__', 'No results were found.', 'emulsify') }}

    + {% endif %} {% endblock %} diff --git a/templates/sidebar.twig b/templates/sidebar.twig deleted file mode 100644 index ae3e17b..0000000 --- a/templates/sidebar.twig +++ /dev/null @@ -1 +0,0 @@ -Sidebar in Timber. Add HTML to your hearts content. \ No newline at end of file diff --git a/templates/single-password.twig b/templates/single-password.twig index 1a87e71..424a6f2 100644 --- a/templates/single-password.twig +++ b/templates/single-password.twig @@ -1,9 +1,33 @@ -{% extends "@templates/_default.twig" %} +{# Password-protected content fallback. WordPress validates the password at + wp-login.php?action=postpass, matching core's standard flow. #} +{% extends '@templates/layouts/base.twig' %} + +{% set password_form_attributes = { + class: bem('password-form'), + action: site.link|default('') ~ '/wp-login.php?action=postpass', + method: 'post' +} %} +{% set password_input_attributes = { + class: bem('input', [], 'password-form'), + name: 'post_password', + id: 'pwbox-' ~ (post.id|default('')), + type: 'password', + autocomplete: 'current-password' +} %} +{% set password_submit_attributes = { + class: bem('submit', [], 'password-form'), + type: 'submit', + name: 'Submit' +} %} {% block content %} -
    - - - -
    + {% if post|default(null) %} +
    + + + +
    + {% else %} +

    {{ function('__', 'This content is password protected.', 'emulsify') }}

    + {% endif %} {% endblock %} diff --git a/templates/single.twig b/templates/single.twig index 6dcdc41..2bce91b 100644 --- a/templates/single.twig +++ b/templates/single.twig @@ -1,45 +1,96 @@ -{% extends "@templates/_default.twig" %} +{# Generic single content fallback. single.php can prepend single-{post_type}.twig + for child themes that need post-type-specific markup. #} +{% extends '@templates/layouts/base.twig' %} + +{% set entry_attributes = { + class: bem('entry', [post.type|default('post')]), + id: post.id|default(null) ? 'post-' ~ post.id : null +} %} +{% set entry_media_attributes = { + class: bem('media', [], 'entry') +} %} +{% set entry_header_attributes = { + class: bem('header', [], 'entry') +} %} +{% set entry_title_attributes = { + class: bem('title', [], 'entry') +} %} +{% set entry_meta_attributes = { + class: bem('meta', [], 'entry') +} %} +{% set entry_author_attributes = { + class: bem('author', [], 'entry') +} %} +{% set entry_date_attributes = { + class: bem('date', [], 'entry'), + datetime: post.date|default(null) ? post.date|date('Y-m-d H:i:s') : null +} %} +{% set entry_content_attributes = { + class: bem('content', [], 'entry') +} %} +{% set comments_attributes = { + class: bem('comments'), + 'aria-label': function('__', 'Comments', 'emulsify') +} %} +{% set comments_title_attributes = { + class: bem('title', [], 'comments') +} %} {% block content %} -
    -
    - -
    -

    {{ post.title }}

    - {% if user.can('edit_posts') %} - {% include "@atoms/links/link/link.twig" with { - link_base_class: 'button', - link_url: site.url ~ '/wp-admin/post.php?post=' ~ post.id ~ '&action=edit', - link_content: "Edit Post", - } %} - {% endif %} -

    - By {{ post.author.name }} -

    -
    - {{post.content}} -
    -
    - - -
    - -
    - {% if post.comments %} -

    comments

    - {% for cmt in post.comments %} - {% include "comment.twig" with {comment:cmt} %} - {% endfor %} - {% endif %} -
    - - {% if post.comment_status == "closed" %} -

    comments for this post are closed

    - {% else %} - - {% include "comment-form.twig" %} - {% endif %} -
    -
    -
    + {% if post|default(null) %} +
    + {% if post.thumbnail|default(null) and post.thumbnail.src|default(null) %} +
    + {{ post.thumbnail.alt|default(post.title) }} +
    + {% endif %} + +
    +

    {{ post.title }}

    + + {% if post.author|default(null) or post.date|default(null) %} +
    + {% if post.author|default(null) %} + + {{ post.author.name }} + + {% endif %} + + {% if post.date|default(null) %} + + {% endif %} +
    + {% endif %} +
    + +
    + {{ post.content }} +
    + + {% set comments = post.comments|default([]) %} + {% if comments is not empty or post.comment_status|default('closed') != 'closed' %} +
    + {% if comments is not empty %} +

    {{ function('__', 'Comments', 'emulsify') }}

    + + {% for comment in comments %} + {% include '@templates/partials/comment.twig' with { + comment: comment + } only %} + {% endfor %} + {% endif %} + + {% if post.comment_status|default('closed') != 'closed' %} + {% include '@templates/partials/comment-form.twig' with { + post: post, + user: user|default(wp.user|default(null)), + site: site + } only %} + {% endif %} +
    + {% endif %} +
    + {% else %} +

    {{ function('__', 'No post content was found.', 'emulsify') }}

    + {% endif %} {% endblock %} diff --git a/templates/tease-post.twig b/templates/tease-post.twig deleted file mode 100644 index 420566c..0000000 --- a/templates/tease-post.twig +++ /dev/null @@ -1,13 +0,0 @@ -{% extends "tease.twig" %} - -{% block content %} - {% include "@atoms/text/headings/_heading.twig" with { - heading_level: 2, - heading: post.title, - heading_url: post.link, - } %} -

    {{post.preview.length(25)}}

    - {% if post.thumbnail.src %} - - {% endif %} -{% endblock %} diff --git a/templates/tease.twig b/templates/tease.twig deleted file mode 100644 index 2e2fe15..0000000 --- a/templates/tease.twig +++ /dev/null @@ -1,13 +0,0 @@ -
    - {% block content %} - {% include "@atoms/text/headings/_heading.twig" with { - heading_level: 2, - heading: post.title, - heading_url: post.link, - } %} -

    {{post.preview}}

    - {% if post.get_thumbnail %} - - {% endif %} - {% endblock %} -
    diff --git a/tests/bootstrap.php b/tests/bootstrap.php deleted file mode 100644 index 8eaa39f..0000000 --- a/tests/bootstrap.php +++ /dev/null @@ -1,22 +0,0 @@ -assertTrue(is_array($context)); - } - - function testFunctionsPHP() { - $context = Timber::context(); - $this->assertEquals('StarterSite', get_class($context['site'])); - $this->assertTrue(current_theme_supports('post-thumbnails')); - $this->assertEquals('bar', $context['foo']); - } - - function testLoading() { - $str = Timber::compile('tease.twig'); - $this->assertStringStartsWith('
    ', $str); - $this->assertStringEndsWith('
    ', $str); - } - - /** - * Helper test to output current twig version - */ - function testTwigVersion() { - $str = Timber::compile_string("{{ constant('Twig_Environment::VERSION') }}"); - //error_log('Twig version = '.$str); - } - - function testTwigFilter() { - $str = Timber::compile_string('{{ "foo" | myfoo }}'); - $this->assertEquals('foo bar!', $str); - } - - static function _setupStarterTheme(){ - $dest = WP_CONTENT_DIR . '/themes/' . basename( dirname( dirname( __FILE__ ) ) ); - $src = realpath( __DIR__ . '/../../' . basename( dirname( dirname( __FILE__ ) ) ) ); - if ( is_dir($src) && !file_exists($dest) ) { - symlink($src, $dest); - } - } - - - } diff --git a/theme.json b/theme.json new file mode 100644 index 0000000..97101d9 --- /dev/null +++ b/theme.json @@ -0,0 +1,663 @@ +{ + "$schema": "https://schemas.wp.org/trunk/theme.json", + "version": 2, + "settings": { + "color": { + "defaultPalette": false, + "defaultGradients": false, + "custom": false, + "duotone": [], + "gradients": [], + "customDuotone": false, + "defaultDuotone": false, + "customGradient": false, + "palette": [ + { + "slug": "brand-primary-base", + "name": "brand primary base", + "color": "#005F89" + }, + { + "slug": "brand-primary-light", + "name": "brand primary light", + "color": "#CCECFA" + }, + { + "slug": "brand-primary-light-1", + "name": "brand primary light-1", + "color": "#E6F5FC" + }, + { + "slug": "brand-primary-light-2", + "name": "brand primary light-2", + "color": "#fff" + }, + { + "slug": "brand-primary-dark", + "name": "brand primary dark", + "color": "#00202E" + }, + { + "slug": "brand-primary-dark-1", + "name": "brand primary dark-1", + "color": "#00405B" + }, + { + "slug": "brand-secondary-base", + "name": "brand secondary base", + "color": "#8B1E7E" + }, + { + "slug": "brand-secondary-light", + "name": "brand secondary light", + "color": "#d8aad5" + }, + { + "slug": "brand-secondary-light-1", + "name": "brand secondary light-1", + "color": "#7efbe1" + }, + { + "slug": "brand-secondary-dark", + "name": "brand secondary dark", + "color": "#8b1e7e" + }, + { + "slug": "brand-secondary-dark-1", + "name": "brand secondary dark-1", + "color": "#0b4b3f" + }, + { + "slug": "grayscale-white", + "name": "grayscale-white", + "color": "#ffffff" + }, + { + "slug": "grayscale-gray-1", + "name": "grayscale-gray-1", + "color": "#f1f1f1" + }, + { + "slug": "grayscale-gray-2", + "name": "grayscale-gray-2", + "color": "#e6e6e6" + }, + { + "slug": "grayscale-gray-3", + "name": "grayscale-gray-3", + "color": "#adadad" + }, + { + "slug": "grayscale-gray-4", + "name": "grayscale-gray-4", + "color": "#757575" + }, + { + "slug": "grayscale-gray-5", + "name": "grayscale-gray-5", + "color": "#5c5c5c" + }, + { + "slug": "grayscale-gray-6", + "name": "grayscale-gray-6", + "color": "#454545" + }, + { + "slug": "grayscale-gray-7", + "name": "grayscale-gray-7", + "color": "#1b1b1b" + }, + { + "slug": "grayscale-black", + "name": "grayscale-black", + "color": "#000000" + }, + { + "slug": "other-yellow-base", + "name": "other yellow base", + "color": "#fad980" + }, + { + "slug": "other-yellow-light", + "name": "other yellow light", + "color": "#fff1d2" + }, + { + "slug": "other-yellow-neon-base", + "name": "other yellow-neon base", + "color": "#ff0" + }, + { + "slug": "other-green-base", + "name": "other green base", + "color": "#94bfa2" + }, + { + "slug": "other-green-light", + "name": "other green light", + "color": "#e7f4e4" + }, + { + "slug": "other-red-base", + "name": "other red base", + "color": "#e31c3d" + }, + { + "slug": "other-red-light", + "name": "other red light", + "color": "#e59393" + }, + { + "slug": "other-red-light-1", + "name": "other red light-1", + "color": "#f9dede" + }, + { + "slug": "other-red-dark", + "name": "other red dark", + "color": "#cd2026" + }, + { + "slug": "other-red-dark-1", + "name": "other red dark-1", + "color": "#981b1e" + } + ] + }, + "shadow": { + "defaultPresets": false + }, + "typography": { + "customFontSize": false, + "fontFamilies": [ + { + "name": "Primary", + "slug": "primary", + "fontFamily": "\"Source Sans Pro\", Arial, sans-serif" + }, + { + "name": "System", + "slug": "system", + "fontFamily": "-apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, \"Oxygen-Sans\", Ubuntu, Cantarell, \"Fira Sans\", Droid Sans, sans-serif" + }, + { + "name": "Monospace", + "slug": "monospace", + "fontFamily": "Menlo, Consolas, \"Lucida Console\", \"Liberation Mono\", \"Courier New\", monospace, sans-serif" + } + ], + "fontSizes": [ + { + "name": "1", + "slug": "1", + "size": "12px" + }, + { + "name": "2", + "slug": "2", + "size": "14px" + }, + { + "name": "3", + "slug": "3", + "size": "16px" + }, + { + "name": "4", + "slug": "4", + "size": "17px", + "fluid": { + "min": "17px", + "max": "18px" + } + }, + { + "name": "5", + "slug": "5", + "size": "19px", + "fluid": { + "min": "19px", + "max": "21px" + } + }, + { + "name": "6", + "slug": "6", + "size": "21px", + "fluid": { + "min": "21px", + "max": "23px" + } + }, + { + "name": "7", + "slug": "7", + "size": "23px", + "fluid": { + "min": "23px", + "max": "26px" + } + }, + { + "name": "8", + "slug": "8", + "size": "26px", + "fluid": { + "min": "26px", + "max": "30px" + } + }, + { + "name": "9", + "slug": "9", + "size": "28px", + "fluid": { + "min": "28px", + "max": "34px" + } + }, + { + "name": "10", + "slug": "10", + "size": "31px", + "fluid": { + "min": "31px", + "max": "39px" + } + }, + { + "name": "11", + "slug": "11", + "size": "34px", + "fluid": { + "min": "34px", + "max": "44px" + } + }, + { + "name": "12", + "slug": "12", + "size": "38px", + "fluid": { + "min": "38px", + "max": "50px" + } + }, + { + "name": "13", + "slug": "13", + "size": "41px", + "fluid": { + "min": "41px", + "max": "56px" + } + }, + { + "name": "14", + "slug": "14", + "size": "46px", + "fluid": { + "min": "46px", + "max": "64px" + } + }, + { + "name": "15", + "slug": "15", + "size": "50px", + "fluid": { + "min": "50px", + "max": "72px" + } + } + ] + }, + "spacing": { + "spacingScale": { + "operator": "*", + "increment": 1.5, + "steps": 7, + "mediumStep": 1.5, + "unit": "rem" + }, + "spacingSizes": [ + { + "slug": "0", + "size": "0", + "name": "0" + }, + { + "slug": "1", + "size": "0.25rem", + "name": "0.25rem" + }, + { + "slug": "2", + "size": "0.5rem", + "name": "0.5rem" + }, + { + "slug": "3", + "size": "0.75rem", + "name": "0.75rem" + }, + { + "slug": "4", + "size": "1rem", + "name": "1rem" + }, + { + "slug": "5", + "size": "1.25rem", + "name": "1.25rem" + }, + { + "slug": "6", + "size": "1.5rem", + "name": "1.5rem" + }, + { + "slug": "7", + "size": "2rem", + "name": "2rem" + }, + { + "slug": "8", + "size": "2.5rem", + "name": "2.5rem" + }, + { + "slug": "9", + "size": "3rem", + "name": "3rem" + }, + { + "slug": "10", + "size": "3.5rem", + "name": "3.5rem" + }, + { + "slug": "11", + "size": "4rem", + "name": "4rem" + }, + { + "slug": "12", + "size": "4.5rem", + "name": "4.5rem" + }, + { + "slug": "13", + "size": "5rem", + "name": "5rem - Default" + }, + { + "slug": "14", + "size": "5.5rem", + "name": "5.5rem" + }, + { + "slug": "15", + "size": "6rem", + "name": "6rem" + }, + { + "slug": "16", + "size": "6.5rem", + "name": "6.5rem" + }, + { + "slug": "17", + "size": "7rem", + "name": "7rem" + }, + { + "slug": "18", + "size": "7.5rem", + "name": "7.5rem" + } + ] + }, + "layout": { + "contentSize": "1440px", + "wideSize": "2200px" + }, + "useRootPaddingAwareAlignments": true, + "custom": { + "box-shadow": { + "0": "none", + "1": "0 1px 3px rgba(0, 0, 0, 0.12), 0 1px 2px rgba(0, 0, 0, 0.24)", + "2": "0 3px 6px rgba(0, 0, 0, 0.16), 0 3px 6px rgba(0, 0, 0, 0.23)", + "3": "0 10px 20px rgba(0, 0, 0, 0.19), 0 6px 6px rgba(0, 0, 0, 0.23)", + "4": "0 14px 28px rgba(0, 0, 0, 0.25), 0 10px 10px rgba(0, 0, 0, 0.22)", + "5": "0 19px 38px rgba(0, 0, 0, 0.3), 0 15px 12px rgba(0, 0, 0, 0.22)" + }, + "constrain": { + "sm": "800px", + "md": "1440px", + "lg": "2200px" + }, + "line-height": { + "short": 1.1, + "base": 1.45, + "loose": 1.7 + }, + "transitions": { + "ease-in-out": "cubic-bezier(0.4, 0, 0.2, 1)", + "ease-out": "cubic-bezier(0.0, 0, 0.2, 1)", + "ease-in": "cubic-bezier(0.4, 0, 1, 1)", + "sharp": "cubic-bezier(0.4, 0, 0.6, 1)", + "duration-shortest": "150ms", + "duration-short": "200ms", + "duration-standard": "375ms", + "duration-long": "400ms", + "duration-intro": "270ms", + "duration-outro": "195ms" + }, + "z-index": { + "nav": 1000, + "drawer": 1200, + "modal": 1300, + "skiplinks": 9999 + } + }, + "blocks": { + "core/group": { + "typography": { + "fontSizes": [], + "fontFamilies": [], + "letterSpacing": false, + "lineHeight": false, + "dropCap": false, + "textDecoration": false, + "fontWeight": false, + "fontStyle": false, + "textTransform": false + } + }, + "core/heading": { + "typography": { + "fontSizes": [], + "fontFamilies": [], + "letterSpacing": false, + "lineHeight": false, + "dropCap": false, + "textDecoration": false, + "fontWeight": false, + "fontStyle": false, + "textTransform": false + } + }, + "core/quote": { + "typography": { + "fontSizes": [], + "fontFamilies": [], + "letterSpacing": false, + "lineHeight": false, + "dropCap": false, + "textDecoration": false, + "fontWeight": false, + "fontStyle": false, + "textTransform": false + } + }, + "core/paragraph": { + "typography": { + "fontFamilies": [], + "lineHeight": false, + "textDecoration": false, + "fontWeight": false, + "fontStyle": false, + "textTransform": false + } + } + } + }, + "styles": { + "spacing": { + "padding": { + "left": "1rem", + "right": "1rem" + } + }, + "elements": { + "link": { + "border": {}, + "color": { + "text": "var(--wp--preset--color--brand-primary-light)" + }, + ":hover": { + "color": { + "text": "var(---wp--preset--color--brand-primary)" + } + }, + ":focus": { + "color": { + "text": "var(---wp--preset--color--brand-primary)" + } + }, + ":visited": { + "color": { + "text": "var(--wp--preset--color--brand-primary-dark)" + } + }, + ":active": { + "color": { + "text": "var(---wp--preset--color--brand-primary)" + } + }, + "spacing": {}, + "typography": {} + }, + "h1": { + "color": { + "text": "var(--wp--preset--color--grayscale-black)" + }, + "typography": { + "fontFamily": "var(--wp--preset--font-family--primary)", + "fontSize": "var(--wp--preset--font-size--12)" + } + }, + "h2": { + "color": { + "text": "var(--wp--preset--color--grayscale-black)" + }, + "typography": { + "fontFamily": "var(--wp--preset--font-family--primary)", + "fontSize": "var(--wp--preset--font-size--10)" + } + }, + "h3": { + "color": { + "text": "var(--wp--preset--color--grayscale-black)" + }, + "typography": { + "fontFamily": "var(--wp--preset--font-family--primary)", + "fontSize": "var(--wp--preset--font-size--8)" + } + }, + "h4": { + "color": { + "text": "var(--wp--preset--color--grayscale-black)" + }, + "typography": { + "fontFamily": "var(--wp--preset--font-family--primary)", + "fontSize": "var(--wp--preset--font-size--7)" + } + }, + "h5": { + "color": { + "text": "var(--wp--preset--color--grayscale-black)" + }, + "typography": { + "fontFamily": "var(--wp--preset--font-family--primary)", + "fontSize": "var(--wp--preset--font-size--3)" + } + }, + "h6": { + "color": { + "text": "var(--wp--preset--color--grayscale-black)" + }, + "typography": { + "fontFamily": "var(--wp--preset--font-family--primary)", + "fontSize": "var(--wp--preset--font-size--2)" + } + } + }, + "blocks": { + "core/button": { + "border": { + "radius": "0", + "width": "0", + "color": { + "background": "var(--wp--preset--color--grayscale-black)", + "text": "var(--wp--preset--color--grayscale-white)" + } + }, + "spacing": { + "padding": { + "bottom": "var(--wp--preset--spacing--2)", + "left": "var(--wp--preset--spacing--4)", + "right": "var(--wp--preset--spacing--4)", + "top": "var(--wp--preset--spacing--2)" + } + } + }, + "core/quote": { + "color": { + "text": "var(--wp--preset--color--brand-primary-light)" + }, + "spacing": { + "margin": { + "top": "0", + "right": "0", + "bottom": "var(--wp--preset--spacing--medium)", + "left": "0" + } + }, + "typography": { + "fontFamily": "var(--wp--preset--font-family--primary)", + "fontSize": "var(--wp--preset--font-size--7)", + "fontWeight": "400" + }, + "elements": { + "cite": { + "color": { + "text": "var(--wp--preset--color--brand-primary-dark)" + }, + "typography": { + "fontFamily": "var(--wp--preset--font-family--primary)", + "fontSize": "var(--wp--preset--font-size--3)", + "fontWeight": "400" + }, + "spacing": { + "margin": { + "top": "var(--wp--preset--spacing--medium)" + } + } + } + } + } + } + } +} diff --git a/webpack/.stylelintrc b/webpack/.stylelintrc deleted file mode 100644 index 055dcfd..0000000 --- a/webpack/.stylelintrc +++ /dev/null @@ -1,24 +0,0 @@ -{ - "extends": [ - "stylelint-config-standard", - "stylelint-config-prettier" - ], - "rules": { - "at-rule-no-unknown": [ true, { - "ignoreAtRules": [ - "include", - "mixin", - "extend", - "if", - "else", - "each", - "content", - "import", - "function", - "return" - ] - }], - "no-descending-specificity": null, - "function-calc-no-invalid": null - } -} diff --git a/webpack/app.js b/webpack/app.js deleted file mode 100644 index 824fcb3..0000000 --- a/webpack/app.js +++ /dev/null @@ -1 +0,0 @@ -// Empty (needed for webpack) diff --git a/webpack/css.js b/webpack/css.js deleted file mode 100644 index f470d23..0000000 --- a/webpack/css.js +++ /dev/null @@ -1 +0,0 @@ -import '../components/style.scss'; diff --git a/webpack/loaders.js b/webpack/loaders.js deleted file mode 100644 index c5d50c9..0000000 --- a/webpack/loaders.js +++ /dev/null @@ -1,61 +0,0 @@ -const MiniCssExtractPlugin = require('mini-css-extract-plugin'); -const globImporter = require('node-sass-glob-importer'); - -const JSLoader = { - test: /^(?!.*\.(stories|component)\.js$).*\.js$/, - exclude: /node_modules/, - loader: 'babel-loader', -}; - -const ImageLoader = { - test: /\.(svg)$/i, - exclude: /icons\/.*\.svg$/, - loader: 'file-loader', -}; - -const CSSLoader = { - test: /\.s[ac]ss$/i, - exclude: /node_modules/, - use: [ - MiniCssExtractPlugin.loader, - { - loader: 'css-loader', - options: { - sourceMap: true, - url: false, - }, - }, - { - loader: 'postcss-loader', - options: { - sourceMap: true, - }, - }, - { - loader: 'sass-loader', - options: { - sourceMap: true, - sassOptions: { - importer: globImporter(), - outputStyle: 'compressed', - }, - }, - }, - ], -}; - -const SVGSpriteLoader = { - test: /icons\/.*\.svg$/, // your icons directory - loader: 'svg-sprite-loader', - options: { - extract: true, - spriteFilename: '../dist/icons.svg', - }, -}; - -module.exports = { - JSLoader, - CSSLoader, - SVGSpriteLoader, - ImageLoader, -}; diff --git a/webpack/plugins.js b/webpack/plugins.js deleted file mode 100644 index d391d4d..0000000 --- a/webpack/plugins.js +++ /dev/null @@ -1,41 +0,0 @@ -/* eslint-disable no-underscore-dangle */ -const path = require('path'); -const webpack = require('webpack'); -const { CleanWebpackPlugin } = require('clean-webpack-plugin'); -const _MiniCssExtractPlugin = require('mini-css-extract-plugin'); -const _ImageminPlugin = require('imagemin-webpack-plugin').default; -const _SpriteLoaderPlugin = require('svg-sprite-loader/plugin'); -const glob = require('glob'); - -const imagePath = path.resolve(__dirname, '../images'); - -const MiniCssExtractPlugin = new _MiniCssExtractPlugin({ - filename: 'style.css', - chunkFilename: '[id].css', -}); - -const ImageminPlugin = new _ImageminPlugin({ - disable: process.env.NODE_ENV !== 'production', - externalImages: { - context: imagePath, - sources: glob.sync(path.resolve(imagePath, '**/*.{png,jpg,gif,svg}')), - destination: imagePath, - }, -}); - -const SpriteLoaderPlugin = new _SpriteLoaderPlugin({ - plainSprite: true, -}); - -const ProgressPlugin = new webpack.ProgressPlugin(); - -module.exports = { - ProgressPlugin, - MiniCssExtractPlugin, - ImageminPlugin, - SpriteLoaderPlugin, - CleanWebpackPlugin: new CleanWebpackPlugin({ - cleanOnceBeforeBuildPatterns: ['!*.{png,jpg,gif,svg}'], - cleanAfterEveryBuildPatterns: ['remove/**', '!js', '!*.{png,jpg,gif,svg}'], - }), -}; diff --git a/webpack/svgSprite.js b/webpack/svgSprite.js deleted file mode 100644 index b5873bf..0000000 --- a/webpack/svgSprite.js +++ /dev/null @@ -1,5 +0,0 @@ -function requireAll(r) { - r.keys().forEach(r); -} - -requireAll(require.context('../images/', true, /\.svg$/)); diff --git a/webpack/webpack.common.js b/webpack/webpack.common.js deleted file mode 100644 index ddd8960..0000000 --- a/webpack/webpack.common.js +++ /dev/null @@ -1,54 +0,0 @@ -const path = require('path'); -const glob = require('glob'); -const loaders = require('./loaders'); -const plugins = require('./plugins'); - -const webpackDir = path.resolve(__dirname); -const rootDir = path.resolve(__dirname, '..'); -const distDir = path.resolve(rootDir, 'dist'); - -function getEntries(pattern) { - const entries = {}; - - glob.sync(pattern).forEach((file) => { - const filePath = file.split('components/')[1]; - const newfilePath = `js/${filePath.replace('.js', '')}`; - entries[newfilePath] = file; - }); - - entries.svgSprite = path.resolve(webpackDir, 'svgSprite.js'); - entries.css = path.resolve(webpackDir, 'css.js'); - - return entries; -} - -module.exports = { - stats: { - errorDetails: true, - }, - entry: getEntries( - path.resolve( - rootDir, - 'components/**/!(*.stories|*.component|*.min|*.test).js', - ), - ), - module: { - rules: [ - loaders.CSSLoader, - loaders.SVGSpriteLoader, - loaders.ImageLoader, - loaders.JSLoader, - ], - }, - plugins: [ - plugins.MiniCssExtractPlugin, - plugins.ImageminPlugin, - plugins.SpriteLoaderPlugin, - plugins.ProgressPlugin, - plugins.CleanWebpackPlugin, - ], - output: { - path: distDir, - filename: '[name].js', - }, -}; diff --git a/webpack/webpack.dev.js b/webpack/webpack.dev.js deleted file mode 100644 index ee7ea0c..0000000 --- a/webpack/webpack.dev.js +++ /dev/null @@ -1,7 +0,0 @@ -const { merge } = require('webpack-merge'); -const common = require('./webpack.common.js'); - -module.exports = merge(common, { - mode: 'development', - devtool: 'source-map', -}); diff --git a/webpack/webpack.prod.js b/webpack/webpack.prod.js deleted file mode 100644 index ed0670e..0000000 --- a/webpack/webpack.prod.js +++ /dev/null @@ -1,6 +0,0 @@ -const { merge } = require('webpack-merge'); -const common = require('./webpack.common.js'); - -module.exports = merge(common, { - mode: 'production', -}); diff --git a/whisk/.cli/init.js b/whisk/.cli/init.js new file mode 100644 index 0000000..448a127 --- /dev/null +++ b/whisk/.cli/init.js @@ -0,0 +1,189 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const STARTER_SLUG = 'whisk'; +const PARENT_THEME = 'emulsify'; +const GENERATED_FROM = 'emulsify-wordpress'; +const FALLBACK_GENERATED_FROM_VERSION = '2.0.0'; +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +const projectConfigPath = path.join(ROOT, 'project.emulsify.json'); + +const isPlainObject = (value) => + value !== null && typeof value === 'object' && !Array.isArray(value); + +const readText = (filePath) => fs.readFileSync(filePath, 'utf8'); + +const writeTextIfChanged = (filePath, contents) => { + if (readText(filePath) !== contents) { + fs.writeFileSync(filePath, contents); + } +}; + +const readJson = (filePath) => JSON.parse(readText(filePath)); + +const writeJsonIfChanged = (filePath, data) => { + const contents = `${JSON.stringify(data, null, 2)}\n`; + writeTextIfChanged(filePath, contents); +}; + +const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + +const replaceThemeHeader = (contents, field, value) => { + const pattern = new RegExp(`^(\\s*\\*\\s*${escapeRegExp(field)}:\\s*).*$`, 'mi'); + let found = false; + const updated = contents.replace(pattern, (_match, prefix) => { + found = true; + return `${prefix}${value}`; + }); + + if (!found) { + throw new Error(`Could not update "${field}" in style.css.`); + } + + return updated; +}; + +const getProjectConfig = () => { + const config = readJson(projectConfigPath); + + if (!isPlainObject(config.project)) { + throw new Error('project.emulsify.json must contain a project object.'); + } + + const { name, machineName } = config.project; + + if (typeof name !== 'string' || name.trim() === '') { + throw new Error('project.emulsify.json project.name must be a non-empty string.'); + } + + if (typeof machineName !== 'string' || machineName.trim() === '') { + throw new Error('project.emulsify.json project.machineName must be a non-empty string.'); + } + + return config; +}; + +const updateStyleCss = ({ name, machineName }) => { + const filePath = path.join(ROOT, 'style.css'); + let contents = readText(filePath); + + contents = replaceThemeHeader(contents, 'Theme Name', name); + contents = replaceThemeHeader(contents, 'Text Domain', machineName); + contents = replaceThemeHeader(contents, 'Template', PARENT_THEME); + + writeTextIfChanged(filePath, contents); +}; + +const updatePackageJson = ({ machineName }) => { + const filePath = path.join(ROOT, 'package.json'); + const data = readJson(filePath); + + data.name = machineName; + + writeJsonIfChanged(filePath, data); +}; + +const getGeneratedFromVersion = () => { + const data = readJson(path.join(ROOT, 'package.json')); + + return typeof data.version === 'string' && data.version.trim() !== '' + ? data.version.trim() + : FALLBACK_GENERATED_FROM_VERSION; +}; + +const updateLockfile = (relativePath, { machineName }) => { + const filePath = path.join(ROOT, relativePath); + + if (!fs.existsSync(filePath)) { + return; + } + + const data = readJson(filePath); + + data.name = machineName; + + if (isPlainObject(data.packages) && isPlainObject(data.packages[''])) { + data.packages[''].name = machineName; + } + + writeJsonIfChanged(filePath, data); +}; + +const updateProjectConfig = (config, { name, machineName, generatedFromVersion }) => { + config.project.platform = 'wordpress'; + config.project.name = name; + config.project.machineName = machineName; + config.project.generatedFrom = GENERATED_FROM; + config.project.generatedFromVersion = generatedFromVersion; + + writeJsonIfChanged(projectConfigPath, config); +}; + +const updateFunctionsPhp = ({ name }) => { + const filePath = path.join(ROOT, 'functions.php'); + const contents = readText(filePath).replace( + 'Whisk child theme hooks.', + `${name} child theme hooks.`, + ); + + writeTextIfChanged(filePath, contents); +}; + +const updatePageTemplate = ({ machineName }) => { + const filePath = path.join(ROOT, 'templates/page.twig'); + const contents = readText(filePath).replaceAll( + `${STARTER_SLUG}-page`, + `${machineName}-page`, + ); + + writeTextIfChanged(filePath, contents); +}; + +const walkJsonFiles = (directory) => { + if (!fs.existsSync(directory)) { + return []; + } + + return fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const filePath = path.join(directory, entry.name); + + if (entry.isDirectory()) { + return walkJsonFiles(filePath); + } + + return entry.isFile() && entry.name.endsWith('.json') ? [filePath] : []; + }); +}; + +const updatePatternNamespaces = ({ machineName }) => { + for (const filePath of walkJsonFiles(path.join(ROOT, 'patterns'))) { + const data = readJson(filePath); + + if (typeof data.name === 'string' && data.name.startsWith(`${STARTER_SLUG}/`)) { + data.name = `${machineName}/${data.name.slice(STARTER_SLUG.length + 1)}`; + writeJsonIfChanged(filePath, data); + } + } +}; + +const main = () => { + const config = getProjectConfig(); + const project = { + name: config.project.name, + machineName: config.project.machineName, + generatedFromVersion: getGeneratedFromVersion(), + }; + + updateStyleCss(project); + updatePackageJson(project); + updateLockfile('package-lock.json', project); + updateLockfile('npm-shrinkwrap.json', project); + updateProjectConfig(config, project); + updateFunctionsPhp(project); + updatePageTemplate(project); + updatePatternNamespaces(project); +}; + +main(); diff --git a/whisk/.gitignore b/whisk/.gitignore new file mode 100644 index 0000000..7cf6521 --- /dev/null +++ b/whisk/.gitignore @@ -0,0 +1,14 @@ +# Dependencies +node_modules + +# Build output +dist +.out + +# Test and cache output +.coverage +.cache + +# Local files +.DS_Store +.env diff --git a/whisk/.nvmrc b/whisk/.nvmrc new file mode 100644 index 0000000..a45fd52 --- /dev/null +++ b/whisk/.nvmrc @@ -0,0 +1 @@ +24 diff --git a/whisk/assets/fonts/.gitkeep b/whisk/assets/fonts/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/whisk/assets/icons/.gitkeep b/whisk/assets/icons/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/whisk/assets/images/.gitkeep b/whisk/assets/images/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/whisk/config/.env.example b/whisk/config/.env.example new file mode 100644 index 0000000..f9a1e67 --- /dev/null +++ b/whisk/config/.env.example @@ -0,0 +1,4 @@ + +# Added by Figma Token Engine +FIGMA_PERSONAL_ACCESS_TOKEN="" # Your personal Figma Personal Access token https://www.figma.com/developers/api#access-tokens +FIGMA_FILE_URL="" # URL of the Figma file with the tokens diff --git a/whisk/config/acf-json/.gitkeep b/whisk/config/acf-json/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/whisk/config/acf-json/.gitkeep @@ -0,0 +1 @@ + diff --git a/whisk/config/emulsify-core/.prettierignore b/whisk/config/emulsify-core/.prettierignore new file mode 100644 index 0000000..e8c9426 --- /dev/null +++ b/whisk/config/emulsify-core/.prettierignore @@ -0,0 +1,4 @@ +dist +.out +.coverage +*.min.js diff --git a/whisk/config/emulsify-core/a11y.config.js b/whisk/config/emulsify-core/a11y.config.js new file mode 100644 index 0000000..6c91d39 --- /dev/null +++ b/whisk/config/emulsify-core/a11y.config.js @@ -0,0 +1,18 @@ +export default { + // 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: { + // Example: + // codes: ['WCAG2AA.Principle1.Guideline1_4.1_4_3.G18.Fail'], + codes: [], + // Example: + // descriptions: ['This color pair is supplied by third-party content.'], + descriptions: [], + }, + // List of storybook component IDs defined and used in this project. + // Example: + // components: ['components-example--default'], + components: [], +}; diff --git a/whisk/config/emulsify-core/eslint.config.js b/whisk/config/emulsify-core/eslint.config.js new file mode 100644 index 0000000..391b919 --- /dev/null +++ b/whisk/config/emulsify-core/eslint.config.js @@ -0,0 +1,14 @@ +import { defineConfig } from 'eslint/config'; +import emulsifyCoreConfig from '../../node_modules/@emulsify/core/config/eslint.config.js'; + +// Extend Emulsify Core's shared flat ESLint config by adding project-specific +// config objects after the spread below. +// +// Example: +// { +// files: ['src/**/*.js', 'components/**/*.js'], +// rules: { +// 'no-unused-vars': 'warn', +// }, +// }, +export default defineConfig([...emulsifyCoreConfig]); diff --git a/whisk/config/emulsify-core/prettierrc.config.json b/whisk/config/emulsify-core/prettierrc.config.json new file mode 100644 index 0000000..a20502b --- /dev/null +++ b/whisk/config/emulsify-core/prettierrc.config.json @@ -0,0 +1,4 @@ +{ + "singleQuote": true, + "trailingComma": "all" +} diff --git a/whisk/config/emulsify-core/storybook/main.js b/whisk/config/emulsify-core/storybook/main.js new file mode 100644 index 0000000..f2b0702 --- /dev/null +++ b/whisk/config/emulsify-core/storybook/main.js @@ -0,0 +1,68 @@ +// This file is loaded by Emulsify Core's shared Storybook main config. +// +// Keep the default export as an object. Emulsify Core shallow-merges this object +// into its default Storybook config. Addons are handled specially: project +// addons are appended to Emulsify Core's defaults unless replaceAddons is true. +// +// This file is for Node-side Storybook configuration, such as: +// - addons +// - static asset directories +// - final Storybook config patches +// +// Browser-side story parameters belong in preview.js. +// Storybook manager branding belongs in theme.js. + +// Pass empty config overrides by default so generated themes inherit Emulsify +// Core's stories, framework, Vite builder, Twig handling, and default addons. +const configOverrides = {}; + +// Example: add a project-specific addon and static directory. +// +// Install the addon in the generated theme first: +// npm install --save-dev @storybook/addon-viewport +// +// const configOverrides = { +// addons: ['@storybook/addon-viewport'], +// staticDirs: ['public'], +// }; + +// Example: customize an Emulsify Core default addon without duplicating it. +// +// const configOverrides = { +// addons: [ +// { +// name: '@storybook/addon-a11y', +// options: { +// manual: true, +// }, +// }, +// ], +// }; + +// Example: replace the full addon list instead of appending to Core defaults. +// Use this only when the project intentionally opts out of Emulsify defaults. +// +// export const replaceAddons = true; +// +// const configOverrides = { +// addons: ['@storybook/addon-viewport'], +// }; + +// Example: patch the final Storybook config after Emulsify Core applies the +// default export above. The env object is the normalized project.emulsify.json +// model resolved by Emulsify Core. +// +// export function extendConfig(config, { env }) { +// return { +// ...config, +// staticDirs: +// env.platform === 'wordpress' +// ? [...(config.staticDirs || []), 'storybook-static-assets'] +// : config.staticDirs, +// }; +// } + +// Avoid redefining stories, framework, core.builder, or viteFinal unless the +// project is intentionally replacing Emulsify Core's Storybook integration. + +export default configOverrides; diff --git a/whisk/config/emulsify-core/storybook/manager-head.html b/whisk/config/emulsify-core/storybook/manager-head.html new file mode 100644 index 0000000..e69de29 diff --git a/whisk/config/emulsify-core/storybook/preview-head.html b/whisk/config/emulsify-core/storybook/preview-head.html new file mode 100644 index 0000000..e69de29 diff --git a/whisk/config/emulsify-core/storybook/preview.js b/whisk/config/emulsify-core/storybook/preview.js new file mode 100644 index 0000000..253de17 --- /dev/null +++ b/whisk/config/emulsify-core/storybook/preview.js @@ -0,0 +1,40 @@ +// This file is loaded in Storybook's browser preview iframe through Vite. +// +// Use it for Storybook preview parameters and browser-side imports that stories +// need, such as global CSS. Emulsify Core keeps its default a11y parameters and +// merges this project's parameter overrides into them. +// +// See https://storybook.js.org/docs/writing-stories/parameters#story-parameters. + +// Example: load project CSS into every story. +// +// import '../../../src/global/storybook.css'; + +// Example: override selected Storybook parameters. +// +// export const parameters = { +// layout: 'fullscreen', +// backgrounds: { +// default: 'light', +// values: [ +// { name: 'light', value: '#ffffff' }, +// { name: 'dark', value: '#00202E' }, +// ], +// }, +// controls: { +// expanded: true, +// matchers: { +// color: /(background|color)$/i, +// date: /Date$/i, +// }, +// }, +// a11y: { +// config: { +// detailedReport: true, +// }, +// }, +// }; + +export const parameters = {}; + +export default parameters; diff --git a/whisk/config/emulsify-core/storybook/theme.js b/whisk/config/emulsify-core/storybook/theme.js new file mode 100644 index 0000000..f7b22db --- /dev/null +++ b/whisk/config/emulsify-core/storybook/theme.js @@ -0,0 +1,40 @@ +// To customize your Storybook theme, remove the following line and uncomment the code below. +const storybookTheme = {}; + +// import { create } from '@storybook/theming'; +// const storybookTheme = create({ +// base: 'dark', + +// // UI +// appBg: '#0000ff', +// appContentBg: '#eee', +// appBorderColor: '#ff0000', +// appBorderRadius: 4, + +// // Typography +// fontBase: '"Mona Sans", sans-serif', +// fontCode: 'monospace', + +// // Text colors +// textColor: 'white', +// textInverseColor: 'rgba(255,255,255,0.9)', +// textMutedColor: '#E6F5FC', + +// // Toolbar default and active colors +// barTextColor: '#0000ff', +// barSelectedColor: '#eeff00', +// barBg: '#eeff00', + +// // Form colors +// inputBg: 'red', +// inputBorder: 'silver', +// inputTextColor: '#dddddd', +// inputBorderRadius: 4, +// // Branding +// brandTitle: 'Emulsify', +// brandUrl: 'https://emulsify.info', +// brandImage: +// 'https://raw.githubusercontent.com/fourkitchens/emulsify-core/main/assets/images/emulsify-logo-sb.svg?token=GHSAT0AAAAAACIEXLVC5R3KBCX6HGKGTBBSZNYFWMA', +// }); + +export default storybookTheme; diff --git a/whisk/config/emulsify-core/stylelintrc.config.json b/whisk/config/emulsify-core/stylelintrc.config.json new file mode 100644 index 0000000..f4c7901 --- /dev/null +++ b/whisk/config/emulsify-core/stylelintrc.config.json @@ -0,0 +1,5 @@ +{ + "extends": [ + "../../node_modules/@emulsify/core/config/.stylelintrc.json" + ] +} diff --git a/whisk/config/emulsify-core/vite/example.babel.config.cjs b/whisk/config/emulsify-core/vite/example.babel.config.cjs new file mode 100644 index 0000000..733fd57 --- /dev/null +++ b/whisk/config/emulsify-core/vite/example.babel.config.cjs @@ -0,0 +1,22 @@ +// Rename this file to babel.config.cjs only when a project-specific Vite plugin +// or build tool is configured to load Babel. + +module.exports = (api) => { + api.cache(true); + + return { + presets: [ + // Example: + // + // [ + // '@babel/preset-env', + // { + // useBuiltIns: 'entry', + // corejs: 3, + // }, + // ], + // ['minify', { builtIns: false }], + ], + comments: false, + }; +}; diff --git a/whisk/config/emulsify-core/vite/example.plugins.js b/whisk/config/emulsify-core/vite/example.plugins.js new file mode 100644 index 0000000..cb88510 --- /dev/null +++ b/whisk/config/emulsify-core/vite/example.plugins.js @@ -0,0 +1,29 @@ +// Rename this file to plugins.js, plugins.mjs, or plugins.cjs when the project +// needs to extend Emulsify Core's Vite config. +// +// This file is intentionally a no-op example. Uncomment only the pieces your +// project needs. + +// Example: add Vite plugins from project dependencies. +// +// import Inspect from 'vite-plugin-inspect'; +// +// export default function projectPlugins({ env }) { +// return [ +// Inspect(), +// ]; +// } + +export default []; + +// Example: patch the final Vite config after Emulsify Core assembles it. +// Prefer plugin exports for normal framework integration; use extendConfig() +// when a Vite option cannot be expressed through a plugin. +// +// export function extendConfig(config, { env }) { +// return { +// css: { +// postcss: './config/emulsify-core/vite/postcss.config.cjs', +// }, +// }; +// } diff --git a/whisk/config/emulsify-core/vite/example.postcss.config.cjs b/whisk/config/emulsify-core/vite/example.postcss.config.cjs new file mode 100644 index 0000000..420a497 --- /dev/null +++ b/whisk/config/emulsify-core/vite/example.postcss.config.cjs @@ -0,0 +1,10 @@ +// Rename this file to postcss.config.cjs when a project needs its own PostCSS +// setup, then point Vite to it from plugins.js with extendConfig(). + +module.exports = { + plugins: [ + // Example: + // + // require('autoprefixer')(), + ], +}; diff --git a/whisk/config/jest.config.js b/whisk/config/jest.config.js new file mode 100644 index 0000000..1a43401 --- /dev/null +++ b/whisk/config/jest.config.js @@ -0,0 +1,9 @@ +export default { + coverageDirectory: '/.coverage', + testEnvironment: 'node', + testMatch: [ + '/src/**/*.test.{js,cjs,mjs}', + '/components/**/*.test.{js,cjs,mjs}', + '/config/**/*.test.{js,cjs,mjs}', + ], +}; diff --git a/whisk/functions.php b/whisk/functions.php new file mode 100644 index 0000000..8cb4a2f --- /dev/null +++ b/whisk/functions.php @@ -0,0 +1,17 @@ +", + "license": "GPL-2.0-only", + "engines": { + "node": ">=24" + }, + "type": "module", + "scripts": { + "a11y": "npm run storybook-build && node_modules/@emulsify/core/scripts/a11y.js -r", + "audit": "sh -c 'node_modules/@emulsify/core/scripts/audit.js \"$@\"; status=$?; printf \"\\nAudit docs: https://github.com/emulsify-ds/emulsify-core/blob/4.x/docs/migration-4x.md#storybook-migration\\n\"; exit $status' --", + "audit:twig-stories": "sh -c 'node_modules/@emulsify/core/scripts/audit-twig-stories.js \"$@\"; status=$?; printf \"\\nMigration docs: https://github.com/emulsify-ds/emulsify-core/blob/4.x/docs/storybook.md#legacy-twig-story-compatibility\\n\"; exit $status' --", + "build": "npm run ensure-dist && vite build --config node_modules/@emulsify/core/config/vite/vite.config.js", + "coverage": "npm run test && open-cli .coverage/lcov-report/index.html", + "develop": "npm run ensure-dist && concurrently --raw --no-shell npm:vite npm:storybook", + "ensure-dist": "mkdir -p dist", + "format": "npm run lint-fix; npm run prettier-fix", + "lint": "npm run lint-js; npm run lint-styles", + "lint-fix": "npm run lint-js -- --fix; npm run lint-styles -- --fix", + "lint-js": "eslint --config config/emulsify-core/eslint.config.js --no-error-on-unmatched-pattern ./config ./src ./components", + "lint-staged": "lint-staged", + "lint-styles": "stylelint --config config/emulsify-core/stylelintrc.config.json --allow-empty-input './src/**/*.scss' './components/**/*.scss'", + "prettier": "prettier --config config/emulsify-core/prettierrc.config.json --ignore-path config/emulsify-core/.prettierignore --ignore-unknown --no-error-on-unmatched-pattern './config/**/*.{js,cjs,mjs,json,yml,scss,md,html}' './src/**/*.{js,cjs,mjs,json,yml,scss,md,html}' './components/**/*.{js,cjs,mjs,json,yml,scss,md,html}'", + "prettier-fix": "prettier --config config/emulsify-core/prettierrc.config.json --ignore-path config/emulsify-core/.prettierignore --write --ignore-unknown --no-error-on-unmatched-pattern './config/**/*.{js,cjs,mjs,json,yml,scss,md,html}' './src/**/*.{js,cjs,mjs,json,yml,scss,md,html}' './components/**/*.{js,cjs,mjs,json,yml,scss,md,html}'", + "storybook": "NODE_OPTIONS=--no-deprecation storybook dev -c node_modules/@emulsify/core/.storybook --ci -p 6006", + "storybook-build": "npm run build && storybook build -c node_modules/@emulsify/core/.storybook -o .out", + "test": "jest --coverage --passWithNoTests --config ./config/jest.config.js", + "twatch": "jest --no-coverage --watch --verbose --passWithNoTests --config ./config/jest.config.js", + "vite": "vite build --watch --config node_modules/@emulsify/core/config/vite/vite.config.js" + }, + "dependencies": { + "@emulsify/core": "^4.1.0" + }, + "overrides": { + "glob": "^13.0.6", + "locutus": "^3.0.36", + "minimatch@3.0.x": "^3.1.5" + } +} diff --git a/whisk/patterns/.gitkeep b/whisk/patterns/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/whisk/patterns/.gitkeep @@ -0,0 +1 @@ + diff --git a/whisk/project.emulsify.json b/whisk/project.emulsify.json new file mode 100644 index 0000000..934de88 --- /dev/null +++ b/whisk/project.emulsify.json @@ -0,0 +1,12 @@ +{ + "project": { + "platform": "wordpress", + "name": "whisk", + "machineName": "whisk", + "generatedFrom": "emulsify-wordpress", + "generatedFromVersion": "2.0.0" + }, + "starter": { + "repository": "https://github.com/emulsify-ds/emulsify-wordpress-starter" + } +} diff --git a/whisk/screenshot.png b/whisk/screenshot.png new file mode 100644 index 0000000..43e603e Binary files /dev/null and b/whisk/screenshot.png differ diff --git a/whisk/src/components/.gitkeep b/whisk/src/components/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/whisk/style.css b/whisk/style.css new file mode 100644 index 0000000..2c68d9e --- /dev/null +++ b/whisk/style.css @@ -0,0 +1,10 @@ +/* + * Theme Name: Whisk + * Author: you + * Template: emulsify + * Description: A generated WordPress child theme powered by Emulsify Core 4, Vite, Storybook, and Twig. + * Text Domain: whisk + * Version: 2.0.0 + * License: GPL-2.0-only + * License URI: https://www.gnu.org/licenses/old-licenses/gpl-2.0.html +*/ diff --git a/whisk/templates/page.twig b/whisk/templates/page.twig new file mode 100644 index 0000000..de5b101 --- /dev/null +++ b/whisk/templates/page.twig @@ -0,0 +1,9 @@ +{# Generated child-theme example override. It extends the parent-only page + fallback through @emulsify-tpl to avoid recursively extending itself. #} +{% extends '@emulsify-tpl/page.twig' %} + +{% block content %} +
    + {{ parent() }} +
    +{% endblock %}