diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index e5ad988..ef12454 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -6,7 +6,7 @@ ## Validation - [ ] `npm run lint` -- [ ] `composer test:unit` +- [ ] `composer test` - [ ] Manual Drupal verification, when applicable ## Notes diff --git a/.github/scripts/generation-smoke.sh b/.github/scripts/generation-smoke.sh index db0ec79..c9a828b 100755 --- a/.github/scripts/generation-smoke.sh +++ b/.github/scripts/generation-smoke.sh @@ -6,12 +6,21 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" FIXTURE_DIR="${FIXTURE_DIR:-${TMPDIR:-/tmp}/emulsify-tools-generation-smoke}" DRUPAL_VERSION="${DRUPAL_VERSION:-11.3.*}" -EMULSIFY_VERSION="${EMULSIFY_VERSION:-^6}" -TOOLS_VERSION="${TOOLS_VERSION:-1.0.99}" +EMULSIFY_VERSION="${EMULSIFY_VERSION:-^7}" +TOOLS_VERSION="${TOOLS_VERSION:-2.2.x-dev}" DRUSH_VERSION="${DRUSH_VERSION:-^13}" THEME_NAME="${THEME_NAME:-watson}" +THEME_LABEL="${THEME_LABEL:-Watson Theme}" +THEME_DESCRIPTION="${THEME_DESCRIPTION:-Project theme: Starterkit and Drush parity.}" +LEGACY_DEPRECATION_TEXT="legacy Emulsify Drupal 6.x generation path is deprecated" +MISSING_STARTERKIT_TEXT="The Emulsify Whisk Starterkit was not found. Install a compatible Emulsify Drupal 7.x release before generating a child theme." +MISSING_STARTERKIT_CONFIG_TEXT="The Emulsify Whisk Starterkit metadata file whisk.starterkit.yml was not found. Install a compatible Emulsify Drupal 7.x release before generating a child theme." DB_URL="${DB_URL:-sqlite://sites/default/files/.ht.sqlite}" +KEEP_FIXTURE="${KEEP_FIXTURE:-0}" LOCAL_PACKAGE_DIR="${FIXTURE_DIR}/local/emulsify_tools" +MANIFEST_DIR="${FIXTURE_DIR}/tree-manifests" +WHISK_SOURCE_PATH="" +WHISK_SOURCE_BACKUP="" cleanup_fixture() { if [[ -d "$FIXTURE_DIR" ]]; then @@ -20,6 +29,36 @@ cleanup_fixture() { fi } +restore_whisk_source() { + if [[ -z "$WHISK_SOURCE_BACKUP" ]]; then + return 0 + fi + + if [[ -e "$WHISK_SOURCE_BACKUP" || -L "$WHISK_SOURCE_BACKUP" ]]; then + if ! mv -- "$WHISK_SOURCE_BACKUP" "$WHISK_SOURCE_PATH"; then + printf '\nERROR: Unable to restore Whisk source path: %s\n' "$WHISK_SOURCE_PATH" >&2 + return 1 + fi + elif [[ ! -e "$WHISK_SOURCE_PATH" && ! -L "$WHISK_SOURCE_PATH" ]]; then + printf '\nERROR: Whisk source and backup are both missing: %s\n' "$WHISK_SOURCE_PATH" >&2 + return 1 + fi + + WHISK_SOURCE_PATH="" + WHISK_SOURCE_BACKUP="" +} + +finish() { + local status=$? + + trap - EXIT + restore_whisk_source || status=1 + if [[ "$KEEP_FIXTURE" != "1" ]]; then + cleanup_fixture || status=1 + fi + exit "$status" +} + log() { printf '\n==> %s\n' "$*" } @@ -44,21 +83,7 @@ assert_file() { assert_not_exists() { local path="$1" - [[ ! -e "$path" ]] || fail "Unexpected path exists: ${path}" -} - -assert_contains() { - local file="$1" - local expected="$2" - - grep -Fq "$expected" "$file" || fail "Expected ${file} to contain: ${expected}" -} - -assert_matches() { - local file="$1" - local pattern="$2" - - grep -Eq "$pattern" "$file" || fail "Expected ${file} to match: ${pattern}" + [[ ! -e "$path" && ! -L "$path" ]] || fail "Unexpected path exists: ${path}" } assert_command_fails_with() { @@ -73,7 +98,143 @@ assert_command_fails_with() { set -e [[ "$status" -ne 0 ]] || fail "Expected command to fail: $*" - grep -Fq "$expected" <<<"$output" || fail "Expected failed command output to contain: ${expected}" + grep -Fq "$expected" <<<"$output" || fail "Expected failed command output to contain: ${expected} +Command output: +${output}" +} + +write_tree_manifest() { + local root="$1" + local output="$2" + + assert_dir "$root" + mkdir -p "$(dirname "$output")" + + # Dollar signs below are PHP variables. + # shellcheck disable=SC2016 + php -r ' +$root = realpath($argv[1]); +if ($root === false) { + fwrite(STDERR, "Unable to resolve manifest root: {$argv[1]}\n"); + exit(1); +} + +$iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::SELF_FIRST, +); +$entries = []; +foreach ($iterator as $item) { + $path = $item->getPathname(); + $entry = [ + "path" => str_replace(DIRECTORY_SEPARATOR, "/", substr($path, strlen($root) + 1)), + ]; + + if ($item->isLink()) { + $target = readlink($path); + if ($target === false) { + fwrite(STDERR, "Unable to read symlink target: {$path}\n"); + exit(1); + } + $entry["type"] = "symlink"; + $entry["target"] = $target; + } + elseif ($item->isDir()) { + $entry["type"] = "directory"; + } + elseif ($item->isFile()) { + $hash = hash_file("sha256", $path); + if ($hash === false) { + fwrite(STDERR, "Unable to hash file: {$path}\n"); + exit(1); + } + $entry["type"] = "file"; + $entry["sha256"] = $hash; + $entry["executable_mode"] = sprintf("%03o", $item->getPerms() & 0111); + } + else { + $entry["type"] = filetype($path) ?: "unknown"; + } + + $entries[] = $entry; +} + +usort($entries, static fn (array $left, array $right): int => $left["path"] <=> $right["path"]); +$lines = array_map( + static fn (array $entry): string => json_encode($entry, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR), + $entries, +); +if (file_put_contents($argv[2], implode(PHP_EOL, $lines) . PHP_EOL) === false) { + fwrite(STDERR, "Unable to write manifest: {$argv[2]}\n"); + exit(1); +} +' "$root" "$output" || fail "Unable to create tree manifest for ${root}" +} + +assert_manifests_equal() { + local expected="$1" + local actual="$2" + local description="$3" + + if ! diff -u "$expected" "$actual"; then + fail "Tree manifest mismatch: ${description}" + fi +} + +assert_generated_metadata() { + local info_file="$1" + + # Dollar signs below are PHP variables. + # shellcheck disable=SC2016 + php -r ' +require $argv[1]; + +try { + $info = \Drupal\Component\Serialization\Yaml::decode(file_get_contents($argv[2])); +} +catch (Throwable $exception) { + fwrite(STDERR, "Unable to parse {$argv[2]}: {$exception->getMessage()}\n"); + exit(1); +} + +if (!is_array($info)) { + fwrite(STDERR, "Generated info metadata is not a YAML mapping.\n"); + exit(1); +} + +$expected = [ + "name" => $argv[3], + "description" => $argv[4], + "base theme" => "emulsify", +]; +foreach ($expected as $key => $value) { + $actual = $info[$key] ?? null; + if ($actual !== $value) { + fwrite(STDERR, sprintf( + "Metadata mismatch for %s: expected %s, got %s\n", + $key, + var_export($value, true), + var_export($actual, true), + )); + exit(1); + } +} + +$dependencies = $info["dependencies"] ?? null; +if (!is_array($dependencies) || !in_array($argv[5], $dependencies, true)) { + fwrite(STDERR, sprintf( + "Generated dependencies do not contain %s: %s\n", + var_export($argv[5], true), + var_export($dependencies, true), + )); + exit(1); +} +' \ + "${FIXTURE_DIR}/vendor/autoload.php" \ + "$info_file" \ + "$THEME_LABEL" \ + "$THEME_DESCRIPTION" \ + 'drupal:emulsify_tools (^2.0)' || fail "Generated theme metadata validation failed." } command -v composer >/dev/null || fail "composer is required." @@ -84,19 +245,20 @@ fi if [[ -x "$REPO_ROOT/vendor/bin/yaml-lint" ]]; then log "Linting module YAML files" - "$REPO_ROOT/vendor/bin/yaml-lint" \ + "$REPO_ROOT/vendor/bin/yaml-lint" --parse-tags \ "$REPO_ROOT/emulsify_tools.info.yml" \ - "$REPO_ROOT/emulsify_tools.services.yml" \ - "$REPO_ROOT/drush.services.yml" + "$REPO_ROOT/emulsify_tools.services.yml" fi -if [[ "${KEEP_FIXTURE:-0}" != "1" ]]; then - trap cleanup_fixture EXIT -fi +trap finish EXIT +trap 'exit 129' HUP +trap 'exit 130' INT +trap 'exit 143' TERM log "Creating disposable Drupal fixture at ${FIXTURE_DIR}" cleanup_fixture composer create-project "drupal/recommended-project:${DRUPAL_VERSION}" "$FIXTURE_DIR" \ + --no-dev \ --no-interaction \ --no-progress @@ -110,6 +272,8 @@ tar \ -C "$REPO_ROOT" \ -cf - . | tar -C "$LOCAL_PACKAGE_DIR" -xf - +# Dollar signs below are PHP variables. +# shellcheck disable=SC2016 php -r ' $file = $argv[1]; $json = json_decode(file_get_contents($file), true); @@ -119,6 +283,8 @@ file_put_contents($file, json_encode($json, JSON_PRETTY_PRINT | JSON_UNESCAPED_S cd "$FIXTURE_DIR" +# Dollar signs below are PHP variables. +# shellcheck disable=SC2016 repository_json="$(php -r ' echo json_encode([ "type" => "path", @@ -143,6 +309,7 @@ composer require \ "drupal/emulsify_tools:${TOOLS_VERSION}" \ "drush/drush:${DRUSH_VERSION}" \ --with-all-dependencies \ + --update-no-dev \ --no-interaction \ --no-progress @@ -160,48 +327,122 @@ vendor/bin/drush pm:enable emulsify_tools -y vendor/bin/drush theme:enable emulsify -y vendor/bin/drush cr -y -log "Checking that the public Drush command is discoverable" -vendor/bin/drush list | grep -Fq 'emulsify_tools:bake' || fail "Drush command emulsify_tools:bake was not discovered." +log "Checking that the public Drush commands are discoverable" +vendor/bin/drush list --raw | grep -Fq 'emulsify_tools:bake' || fail "Drush command emulsify_tools:bake was not discovered." +vendor/bin/drush list --raw | grep -Fq 'emulsify_tools:repair-favicon-config' || fail "Drush command emulsify_tools:repair-favicon-config was not discovered." vendor/bin/drush help emulsify >/dev/null || fail "Drush help for emulsify failed." vendor/bin/drush help emulsify_tools:bake >/dev/null || fail "Drush help for emulsify_tools:bake failed." +vendor/bin/drush help emulsify_tools:generate-theme >/dev/null || fail "Drush help for emulsify_tools:generate-theme failed." +vendor/bin/drush help emulsify_tools:repair-favicon-config >/dev/null || fail "Drush help for emulsify_tools:repair-favicon-config failed." -log "Generating ${THEME_NAME} with drush emulsify" -vendor/bin/drush emulsify "$THEME_NAME" +if [[ -x vendor/bin/dr ]]; then + drupal_cli=(vendor/bin/dr) +else + drupal_cli=(php web/core/scripts/drupal) +fi + +log "Generating ${THEME_NAME} with Drupal core Starterkit" +"${drupal_cli[@]}" generate-theme "$THEME_NAME" \ + --name="$THEME_LABEL" \ + --description="$THEME_DESCRIPTION" \ + --starterkit=whisk \ + --path=themes/custom \ + --no-interaction theme_dir="web/themes/custom/${THEME_NAME}" info_file="${theme_dir}/${THEME_NAME}.info.yml" +core_theme_dir="${FIXTURE_DIR}/core-generated/${THEME_NAME}" +mkdir -p "$(dirname "$core_theme_dir")" +mv "$theme_dir" "$core_theme_dir" + +log "Generating ${THEME_NAME} with drush emulsify" +if ! drush_generation_output="$( + vendor/bin/drush emulsify "$THEME_NAME" \ + --name="$THEME_LABEL" \ + --description="$THEME_DESCRIPTION" 2>&1 +)"; then + printf '%s\n' "$drush_generation_output" + fail "Drush child-theme generation failed." +fi +printf '%s\n' "$drush_generation_output" +if grep -Fq "$LEGACY_DEPRECATION_TEXT" <<<"$drush_generation_output"; then + fail "Emulsify Drupal 7.x generation unexpectedly used the deprecated legacy workflow." +fi + +log "Comparing Drupal core and Drush generated-tree manifests" +core_manifest="${MANIFEST_DIR}/core-generated.jsonl" +drush_manifest="${MANIFEST_DIR}/drush-generated.jsonl" +write_tree_manifest "$core_theme_dir" "$core_manifest" +write_tree_manifest "$theme_dir" "$drush_manifest" +assert_manifests_equal "$core_manifest" "$drush_manifest" "Drupal core and Drush generated different child themes." log "Validating generated child theme files" assert_dir "$theme_dir" assert_file "$info_file" -assert_matches "$info_file" '^[[:space:]]*base theme:[[:space:]]*emulsify[[:space:]]*$' -assert_contains "$info_file" 'drupal:emulsify_tools (^1.0)' +assert_generated_metadata "$info_file" assert_file "${theme_dir}/config/install/${THEME_NAME}.settings.yml" assert_file "${theme_dir}/config/schema/${THEME_NAME}.schema.yml" +assert_file "${theme_dir}/project.emulsify.json" assert_not_exists "${theme_dir}/whisk.info.emulsify.yml" +assert_not_exists "${theme_dir}/whisk.starterkit.yml" +assert_not_exists "${theme_dir}/src/StarterKit.php" assert_not_exists "${theme_dir}/config/install/whisk.settings.yml" assert_not_exists "${theme_dir}/config/schema/whisk.schema.yml" -assert_not_exists "${theme_dir}/project.emulsify.json" +if grep -R -Fq '%%EMULSIFY_' "$theme_dir"; then + fail "Generated child theme contains unresolved documentation placeholders." +fi + +log "Confirming a human-readable positional theme name is normalized" +human_theme_label="Crème Brûlée Theme" +human_theme_name="creme_brulee_theme" +if [[ "$THEME_NAME" == "$human_theme_name" ]]; then + human_theme_label="Another Crème Brûlée Theme" + human_theme_name="another_creme_brulee_theme" +fi +vendor/bin/drush emulsify_tools:generate-theme "$human_theme_label" +assert_dir "web/themes/custom/${human_theme_name}" +assert_file "web/themes/custom/${human_theme_name}/${human_theme_name}.info.yml" log "Confirming existing destination fails safely through drush emulsify_tools:bake" -guard_checksum_before="$(cksum "$info_file")" +guard_manifest_before="${MANIFEST_DIR}/existing-destination-before.jsonl" +guard_manifest_after="${MANIFEST_DIR}/existing-destination-after.jsonl" +write_tree_manifest "$theme_dir" "$guard_manifest_before" assert_command_fails_with \ - "The destination theme already exists: themes/custom/${THEME_NAME}" \ + "Theme could not be generated because the destination directory" \ vendor/bin/drush emulsify_tools:bake "$THEME_NAME" -guard_checksum_after="$(cksum "$info_file")" -[[ "$guard_checksum_before" == "$guard_checksum_after" ]] || fail "Existing destination was modified: ${info_file}" +write_tree_manifest "$theme_dir" "$guard_manifest_after" +assert_manifests_equal "$guard_manifest_before" "$guard_manifest_after" "Existing destination was modified." log "Confirming missing Whisk source fails clearly" emulsify_theme_path="$(vendor/bin/drush php:eval 'echo DRUPAL_ROOT . "/" . \Drupal::service("extension.list.theme")->getPath("emulsify");')" whisk_dir="${emulsify_theme_path}/whisk" -missing_whisk_dir="${whisk_dir}.generation-smoke-missing" assert_dir "$whisk_dir" -mv "$whisk_dir" "$missing_whisk_dir" +WHISK_SOURCE_PATH="$whisk_dir" +WHISK_SOURCE_BACKUP="${WHISK_SOURCE_PATH}.generation-smoke-missing" +mv -- "$WHISK_SOURCE_PATH" "$WHISK_SOURCE_BACKUP" +missing_source_theme="missing_source_theme" +if [[ "$THEME_NAME" == "$missing_source_theme" ]]; then + missing_source_theme="missing_whisk_source_theme" +fi +assert_command_fails_with \ + "$MISSING_STARTERKIT_TEXT" \ + vendor/bin/drush emulsify_tools:bake "$missing_source_theme" +restore_whisk_source || fail "Unable to restore the Whisk source after the missing-source check." +assert_not_exists "web/themes/custom/${missing_source_theme}" + +log "Confirming missing Whisk Starterkit metadata fails clearly" +WHISK_SOURCE_PATH="${whisk_dir}/whisk.starterkit.yml" +WHISK_SOURCE_BACKUP="${WHISK_SOURCE_PATH}.generation-smoke-missing" +mv -- "$WHISK_SOURCE_PATH" "$WHISK_SOURCE_BACKUP" +missing_metadata_theme="missing_starterkit_metadata_theme" +if [[ "$THEME_NAME" == "$missing_metadata_theme" ]]; then + missing_metadata_theme="missing_whisk_metadata_theme" +fi assert_command_fails_with \ - "The Emulsify Whisk source directory was not found:" \ - vendor/bin/drush emulsify_tools:bake missing_source_theme -mv "$missing_whisk_dir" "$whisk_dir" -assert_not_exists "web/themes/custom/missing_source_theme" + "$MISSING_STARTERKIT_CONFIG_TEXT" \ + vendor/bin/drush emulsify_tools:bake "$missing_metadata_theme" +restore_whisk_source || fail "Unable to restore the Whisk source after the missing-metadata check." +assert_not_exists "web/themes/custom/${missing_metadata_theme}" log "Enabling generated child theme" vendor/bin/drush theme:enable "$THEME_NAME" -y diff --git a/.github/workflows/generation-smoke.yml b/.github/workflows/generation-smoke.yml new file mode 100644 index 0000000..fffb394 --- /dev/null +++ b/.github/workflows/generation-smoke.yml @@ -0,0 +1,78 @@ +name: Generation Smoke + +on: + pull_request: + push: + branches: + - main + - "*.x" + +permissions: + contents: read + +jobs: + generation-smoke: + name: Real Drupal generation (Drupal ${{ matrix.drupal-label }} / PHP ${{ matrix.php-version }} / Drush ${{ matrix.drush-label }}) + runs-on: ubuntu-latest + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + include: + - php-version: "8.3" + drupal-project-version: "11.3.*" + drupal-label: "11.3" + drush-version: "^13" + drush-label: "13" + - php-version: "8.5" + drupal-project-version: "dev-main" + drupal-label: "12.x-dev" + drush-version: "14.x-dev@dev" + drush-label: "14.x-dev" + env: + COMPOSER_NO_INTERACTION: "1" + DRUPAL_VERSION: ${{ matrix.drupal-project-version }} + DRUSH_VERSION: ${{ matrix.drush-version }} + EMULSIFY_VERSION: "^7" + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Validate generation script + run: | + bash -n .github/scripts/generation-smoke.sh + shellcheck .github/scripts/generation-smoke.sh + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-version }} + extensions: pdo_sqlite + coverage: none + + - name: Locate Composer cache + id: composer-cache + run: echo "dir=$(composer config cache-files-dir)" >> "$GITHUB_OUTPUT" + + - name: Cache Composer downloads + uses: actions/cache@v4 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: generation-composer-${{ runner.os }}-php${{ matrix.php-version }}-${{ matrix.drupal-label }}-${{ hashFiles('composer.json', '.github/scripts/generation-smoke.sh') }} + restore-keys: | + generation-composer-${{ runner.os }}-php${{ matrix.php-version }}- + generation-composer-${{ runner.os }}- + + - name: Run real Drupal generation smoke test + run: | + set -o pipefail + bash .github/scripts/generation-smoke.sh 2>&1 | tee "$RUNNER_TEMP/generation-smoke.log" + + - name: Upload generation smoke log + if: failure() + uses: actions/upload-artifact@v4 + with: + name: generation-smoke-drupal-${{ matrix.drupal-label }}-php-${{ matrix.php-version }} + path: ${{ runner.temp }}/generation-smoke.log + if-no-files-found: warn + retention-days: 7 diff --git a/.github/workflows/php-compatibility.yml b/.github/workflows/php-compatibility.yml index ed592af..e5f8645 100644 --- a/.github/workflows/php-compatibility.yml +++ b/.github/workflows/php-compatibility.yml @@ -50,8 +50,8 @@ jobs: - name: Run PHP_CodeSniffer run: composer lint:phpcs - unit: - name: PHPUnit (Drupal ${{ matrix.drupal-label }} / PHP ${{ matrix.php-version }}) + phpunit: + name: PHPUnit suites (Drupal ${{ matrix.drupal-label }} / PHP ${{ matrix.php-version }}) runs-on: ubuntu-latest strategy: fail-fast: false @@ -88,8 +88,14 @@ jobs: - name: Run unit tests run: composer test:unit - deprecations: - name: Drupal deprecations (Drupal 12.x-dev / PHP 8.5) + - name: Run integration tests + run: composer test:integration + + - name: Run kernel tests + run: composer test:kernel + + analysis: + name: PHPStan level 8 (Drupal 12.x-dev / PHP 8.5) runs-on: ubuntu-latest steps: - name: Checkout @@ -110,5 +116,5 @@ jobs: - name: Resolve and install Drupal 12 dependencies run: composer update --with-all-dependencies --prefer-dist --no-interaction - - name: Run deprecation analysis - run: composer test:deprecations + - name: Run static analysis + run: composer analyse diff --git a/README.md b/README.md index ddb206f..63ee1e2 100644 --- a/README.md +++ b/README.md @@ -1,46 +1,138 @@ # Emulsify Tools module -This module provides Emulsify Twig extensions, theme-defined Twig namespaces, child theme generation Drush commands, and deployment commands for Emulsify Drupal favicon packages. +Provides Twig extensions, theme Twig namespaces, child-theme generation, and +favicon deployment commands for Emulsify. ## Compatibility -This module targets Drupal `11.3+`, includes Drupal 12 forward compatibility, -and supports PHP `8.3+` for Drupal 11 sites. Drupal 12 compatibility follows -Drupal core's PHP requirements and is tested on PHP `8.5`. +This module requires Drupal `^11.3 || ^12` and PHP `^8.3`. Drupal 12 +compatibility follows Drupal core's PHP requirements and is tested on PHP +`8.5`. The bundled Drush commands follow the Drush 13+ autowiring pattern, and the codebase avoids syntax newer than PHP 8.3 so Drupal 11 sites can keep using their supported PHP 8.3 runtimes. -### Companion theme pairing +### Emulsify Tools 2.2 compatibility -- `emulsify_tools` `^2.0` is intended to pair with Emulsify Drupal `^7.0`. -- The Twig helpers and child theme generator remain broadly useful on their own, - but the favicon migration and admin-theme favicon features expect the - Emulsify 7.x companion theme APIs to be present. -- In short: child theme generation remains available for Emulsify Drupal 6.x - projects, while generated favicon deployment and repair are the Emulsify - Drupal 7.x companion workflows in this 2.x line. +| Emulsify Tools | Emulsify Drupal | Drupal core | PHP | Drush | Child-theme generation backend | +| -------------- | -------------------------- | ---------------- | ------ | ----- | ---------------------------------------------------------------------------- | +| `2.2.x` | `^7.0` | `^11.3` or `^12` | `^8.3` | `13+` | Preferred Drupal core Starterkit consuming Emulsify's Whisk Starterkit | +| `2.2.x` | `^6.0` (override required) | `^11.3` | `^8.3` | `13+` | Deprecated legacy Whisk copy-and-customize generator; removed in Tools 3.0.0 | + +PHP must also satisfy the installed Drupal core release. The forward- +compatibility CI job tests Drupal 12.x development releases on PHP 8.5 with a +Drush 14.x development release. The Emulsify Drupal 6.x compatibility overlap +is limited to Drupal `^11.3` because that theme line supports Drupal `^10.3` or +`^11`, while Tools 2.2 requires Drupal `^11.3` or `^12`. + +Generated favicon deployment, favicon migration, and admin-theme favicon +features require the Emulsify Drupal 7.x companion theme APIs. + +Official Emulsify Drupal 6.x releases declare `drupal/emulsify_tools:^1.0` in +both Composer and theme metadata. Using this Tools 2.2 compatibility path +therefore requires an intentional temporary project override or patch for +those constraints. The fallback preserves generation behavior; it does not +relax dependency declarations in the installed Emulsify 6.x parent theme. + +### Project scope + +- Twig extensions and theme Twig namespaces are provided by this module and do + not require `drupal/emulsify` as a hard Composer dependency. +- Child-theme generation requires an installed compatible Emulsify Drupal theme + and consumes the Whisk source supplied by that theme. +- `favicon-generate`, `favicon-status`, and `favicon-reset` require Emulsify + Drupal 7.x and delegate package operations to its companion favicon APIs. +- `repair-favicon-config` is a separate source-repair workflow. It copies + missing favicon configuration and schema definitions from Emulsify Drupal + 7.x into existing child-theme source files; it does not deploy packages. ## Usage -### Child theme generation +### Child-theme generation + +Emulsify Tools automatically selects generation behavior from the installed +`whisk` source format. Emulsify Drupal 7.x supplies the Whisk Starterkit, +including `whisk.starterkit.yml`, so generation delegates to Drupal core. +A recognizable legacy `whisk.info.emulsify.yml` source with neither +`whisk.info.yml` nor `whisk.starterkit.yml` uses the Emulsify Drupal 6.x +compatibility workflow instead; no command flag is needed. Incomplete or +missing Starterkit sources fail a focused Emulsify preflight, while malformed +modern metadata is reported by Drupal core. Neither case silently falls back. + +For Emulsify Drupal 7.x, these equivalent commands consume the same Whisk +Starterkit and produce the same generated file tree and file contents when +given the same machine name, display name, and description. The parity test +also compares entry types, symlink targets, and regular-file executable +permission bits: + +```bash +php web/core/scripts/drupal generate-theme my_theme \ + --name="My Theme" \ + --description="Project theme" \ + --starterkit=whisk \ + --path=themes/custom \ + --no-interaction + +drush emulsify_tools:bake my_theme \ + --name="My Theme" \ + --description="Project theme" + +drush emulsify my_theme \ + --name="My Theme" \ + --description="Project theme" + +drush emulsify_tools:generate-theme my_theme \ + --name="My Theme" \ + --description="Project theme" +``` + +`emulsify` is an alias of `emulsify_tools:bake`. +`emulsify_tools:generate-theme` is an additional descriptive alias of the same +command. The generated child theme uses `emulsify` as its runtime parent theme +and is created at `web/themes/custom/my_theme` in a standard Composer-based +Drupal project. A human-readable positional value such as +`drush emulsify "My Theme"` remains supported and resolves to `my_theme`. + +| Input | Meaning | Default | +| ----------------- | ---------------------------------------------------------------------------- | ---------------------------- | +| Positional `name` | Machine name or label used to derive the normalized destination machine name | Required | +| `--name` | Human-readable `name` written to the generated `.info.yml` file | Original positional value | +| `--description` | Human-readable `description` written to the generated `.info.yml` file | Empty on the Starterkit path | + +Positional labels are trimmed, transliterated to ASCII, lowercased, and +normalized with single underscores; for example, `Crème Brûlée Theme` resolves +to `creme_brulee_theme`. `--name` does not override that resolved machine name. + +The Emulsify Drupal 6.x compatibility workflow warns that the legacy generation +path is deprecated, will be removed in Emulsify Tools 3.0.0, and should be +replaced by Emulsify Drupal 7.x with Drupal Starterkit generation. Until then, +`--name` is safely applied to legacy generated themes. A nonempty +`--description` replaces the legacy source description; an omitted or empty +description preserves the source default. -Emulsify Tools 2.x still includes the supported Drush workflow for generating -Emulsify Drupal 6.x child themes. Use either command form: +#### Missing Whisk troubleshooting -`drush emulsify_tools:bake [theme_name]` +The generation preflight distinguishes these installation problems: -`drush emulsify [theme_name]` +- **Base theme unavailable:** Drupal cannot discover the `emulsify` theme and + reports `The Emulsify base theme was not found`. +- **Whisk source unavailable:** the discovered base theme does not contain its + `whisk` directory and reports `The Emulsify Whisk Starterkit was not found`. +- **Starterkit metadata unavailable:** the Whisk directory does not contain + `whisk.starterkit.yml` and is not a complete Drupal Starterkit source. -The commands are equivalent. The generated child theme uses `emulsify` as its -runtime parent theme and should be created under the Drupal custom theme path -expected by the command, such as `web/themes/custom/my_theme` in a -Composer-based Drupal project. +Install or update the preferred companion theme and rebuild Drupal's caches: -Drupal core Starterkit-based generation is being prepared for the Emulsify Drupal -7.x release line. For Emulsify Drupal 6.x child theme projects, use Emulsify -Tools for child theme generation. +```bash +composer require "drupal/emulsify:^7" +drush cr +``` + +Then confirm the installed Emulsify theme contains +`whisk/whisk.starterkit.yml`. Errors from a present but malformed modern Whisk +Starterkit remain Drupal core output and never trigger the deprecated legacy +fallback. Generated favicon deployment for Emulsify Drupal 7.x companion themes: @@ -50,7 +142,7 @@ Generated favicon deployment for Emulsify Drupal 7.x companion themes: `drush emulsify_tools:favicon-reset [theme_name]` -Child theme source repair: +Child-theme favicon source repair, separate from package deployment: `drush emulsify_tools:repair-favicon-config` @@ -71,9 +163,9 @@ Emulsify Drupal page requests do not generate missing favicon package files. After config import, `emulsify_tools:favicon-generate` is the supported deployment path for recreating packages from saved portable SVG config. -The favicon commands delegate generation, status, and reset behavior to the -Emulsify Drupal favicon manager instead of duplicating package logic in this -module. +The favicon deployment commands delegate generation, status, and reset behavior +to the Emulsify Drupal 7.x companion favicon manager APIs instead of duplicating +package logic in this module. The optional admin-theme favicon toggle in this module only reuses an already generated Emulsify package on admin routes. It does not replace the Emulsify @@ -234,14 +326,33 @@ This adds the ability to do a `switch/case` function from within Twig templates. Note that the `switch`, `endswitch`, and `case` tags are required and the `default` is optional. -## Updating 6.x to 7.x +## Upgrading Emulsify Drupal 6.x to 7.x + +### Child-theme generation + +Emulsify Tools 2.2 retains the Emulsify Drupal 6.x generator only as a +deprecated compatibility path and emits a warning whenever it is used. Upgrade +the Emulsify parent theme to 7.x before Emulsify Tools 3.0.0 removes that path. +Once the installed `whisk` source contains Starterkit metadata, the same +`drush emulsify` and `drush emulsify_tools:bake` commands and the descriptive +`drush emulsify_tools:generate-theme` alias automatically use Drupal core; no +command configuration change is required. +Existing generated child themes are not rewritten. Generation continues to +protect an existing destination, so use a new machine name unless you have +intentionally removed the old generated directory. + +Remove any temporary Emulsify 6.x dependency override after upgrading the +parent theme and return the project to the normal Emulsify 7.x/Tools 2.x +constraints. + +### Favicon migration Upgrading from Emulsify 6.x to 7.x introduces a new generated favicon workflow. Instead of relying only on legacy theme-level favicon settings, Emulsify 7.x stores a portable SVG source and generated package metadata in theme settings so favicon packages can be regenerated consistently across environments. -### What changes +#### What changes - Active theme settings gain new favicon keys such as `favicon_source_svg`, `favicon_source_filename`, platform-specific color and padding settings, and @@ -264,7 +375,7 @@ package files in each environment. Use and `drush emulsify_tools:favicon-reset [theme_name]` when you intentionally want to remove generated package state. -### Child Theme Source Repair +### Child-theme source repair Run the repair command in the Drupal site root to update older Emulsify-based child theme codebases: @@ -303,41 +414,79 @@ changes after running the command. ### Validation - `npm run lint` -- `composer test:unit` +- `composer test` +- `composer analyse` +- `bash -n .github/scripts/generation-smoke.sh` +- `shellcheck .github/scripts/generation-smoke.sh` - `bash .github/scripts/favicon-command-smoke.sh /path/to/drupal-site [theme_name]` for a prepared integration fixture with Emulsify Drupal 7.x, Emulsify Tools 2.x, and favicon source config. -### Generation Smoke Test +### Emulsify Drupal 7.x Generation Smoke Test -To validate the Emulsify Drupal 6.x child theme generation workflow against this checkout, run: +The required **Generation Smoke / Real Drupal generation** CI matrix runs this +script with Drupal 11.3, PHP 8.3, and Drush 13, plus the claimed forward +compatibility combination of Drupal 12.x-dev, PHP 8.5, and Drush 14.x-dev. +Both jobs install Emulsify Drupal 7.x and this checkout as a local Composer +package. -``` -.github/scripts/generation-smoke.sh +Run the same integration test locally from the repository root: + +```bash +bash .github/scripts/generation-smoke.sh ``` -The script creates a disposable Drupal fixture site, installs Emulsify Drupal -`^6`, installs this 2.x checkout through the script's local `TOOLS_VERSION` -fixture alias, verifies both Drush command help targets, runs -`drush emulsify watson`, validates the generated theme files, and enables the -generated child theme. It intentionally does not test Drupal core Starterkit -generation or the Emulsify Drupal 7.x favicon deployment workflow. +With PHP 8.5 active, reproduce the forward-compatibility job with: -Requirements: Composer and PHP. The default SQLite fixture database also requires `pdo_sqlite`. +```bash +DRUPAL_VERSION=dev-main DRUSH_VERSION=14.x-dev@dev \ + bash .github/scripts/generation-smoke.sh +``` + +The script creates a disposable SQLite site, verifies discovery of +`emulsify_tools:bake`, `emulsify`, `emulsify_tools:generate-theme`, and +`emulsify_tools:repair-favicon-config`, checks `--name` and `--description` +parsing, and verifies that Drupal core and Drush produce the same generated file +tree and file contents. The manifest comparison also records relative paths, +entry types, SHA-256 regular-file hashes, symlink targets, and regular-file +executable permission bits. This is structural and content parity, not complete +filesystem metadata identity: timestamps, ownership, and other permission bits +are not compared. + +The smoke test also parses generated YAML to verify the requested name and +description, the Emulsify base theme, and the Emulsify Tools dependency. It +checks human-readable positional-name normalization, full-tree preservation on +an existing-destination failure, missing-source and missing-metadata diagnostics +with restoration, Starterkit-only file removal, unresolved placeholder removal, +and generated theme enablement. It intentionally exercises only the preferred +7.x Starterkit path; legacy 6.x compatibility is covered by the PHPUnit fixtures. + +Local requirements are Bash, Composer 2, a compatible PHP CLI with `pdo_sqlite`, +and standard Unix utilities (`tar`, `diff`, and `grep`). Composer +installs Drupal, Drush, and Emulsify in the disposable fixture; no pre-existing +Drupal site or global Drush installation is required. ShellCheck is required +only to run the same script lint used by CI. Optional environment variables: ``` +TMPDIR=/tmp FIXTURE_DIR=/tmp/emulsify-tools-generation-smoke DRUPAL_VERSION=11.3.* -EMULSIFY_VERSION=^6 -TOOLS_VERSION=1.0.99 +EMULSIFY_VERSION=^7 +TOOLS_VERSION=2.2.x-dev DRUSH_VERSION=^13 THEME_NAME=watson +THEME_LABEL="Watson Theme" +THEME_DESCRIPTION="Project theme: Starterkit and Drush parity." DB_URL=sqlite://sites/default/files/.ht.sqlite KEEP_FIXTURE=1 ``` +`FIXTURE_DIR` defaults to a directory beneath `TMPDIR`, or beneath `/tmp` when +`TMPDIR` is unset. Set `KEEP_FIXTURE=1` to retain the disposable Drupal site for +inspection; any temporarily renamed Whisk source path is still restored. + ### Committing Changes This repository uses [Conventional Commits](https://www.conventionalcommits.org/) diff --git a/composer.json b/composer.json index 5090775..b0604e8 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "emulsify-ds/emulsify_tools", - "description": "Provides Twig functions and child theme generation for use with Emulsify", + "description": "Provides Twig extensions, theme Twig namespaces, child-theme generation, and favicon deployment commands for Emulsify.", "type": "drupal-module", "homepage": "https://emulsify.info", "license": "GPL-2.0-only", @@ -26,7 +26,8 @@ "drush/drush": "<13.0" }, "suggest": { - "drush/drush": "Install Drush 13+ to use the emulsify_tools:bake and favicon deployment commands." + "drupal/emulsify": "Install Emulsify Drupal for child-theme generation; version 7.x is required for favicon deployment and repair workflows.", + "drush/drush": "Install Drush 13+ to use child-theme generation and favicon deployment and repair commands." }, "scripts": { "lint": [ @@ -34,8 +35,12 @@ ], "lint:phpcs": "phpcs -q", "lint:phpcbf": "phpcbf", - "test:unit": "phpunit --configuration phpunit.xml.dist", - "test:deprecations": "phpstan analyse --configuration phpstan.neon.dist --no-progress --memory-limit=1G --debug" + "test": "phpunit --configuration phpunit.xml.dist", + "test:unit": "phpunit --configuration phpunit.xml.dist --testsuite unit", + "test:integration": "phpunit --configuration phpunit.xml.dist --testsuite integration", + "test:kernel": "phpunit --configuration phpunit.xml.dist --testsuite kernel", + "analyse": "phpstan analyse --configuration phpstan.neon.dist --no-progress --memory-limit=1G --debug", + "test:deprecations": "@analyse" }, "config": { "allow-plugins": { diff --git a/drush.services.yml b/drush.services.yml deleted file mode 100644 index ef38ad0..0000000 --- a/drush.services.yml +++ /dev/null @@ -1,11 +0,0 @@ -services: - emulsify_tools.commands: - class: Drupal\emulsify_tools\Drush\Commands\SubThemeCommands - arguments: - - '@extension.list.theme' - - '@Drupal\emulsify_tools\Archive\StarterRecipeArchiveExtractor' - - '@emulsify_tools.subtheme_generator' - - '@emulsify_tools.filesystem' - - '@Drupal\emulsify_tools\Favicon\ChildThemeFaviconConfigRepairer' - tags: - - { name: drush.command } diff --git a/emulsify_tools.info.yml b/emulsify_tools.info.yml index 141d2d8..01c4e03 100644 --- a/emulsify_tools.info.yml +++ b/emulsify_tools.info.yml @@ -1,5 +1,5 @@ name: Emulsify Tools type: module -description: Toolset of Twig extensions, theme Twig namespaces, child theme generation, and favicon deployment commands. +description: Provides Twig extensions, theme Twig namespaces, child-theme generation, and favicon deployment commands for Emulsify. core_version_requirement: ^11.3 || ^12 package: Emulsify diff --git a/emulsify_tools.services.yml b/emulsify_tools.services.yml index 203f5e8..0ed706f 100644 --- a/emulsify_tools.services.yml +++ b/emulsify_tools.services.yml @@ -37,9 +37,31 @@ services: emulsify_tools.subtheme_generator: class: Drupal\emulsify_tools\SubThemeGenerator arguments: ['@emulsify_tools.filesystem'] + deprecated: 'The "%service_id%" service is deprecated in emulsify_tools:2.2.0 and is removed from emulsify_tools:3.0.0. Use Drupal\emulsify_tools\ThemeGeneration\ThemeGeneratorInterface instead.' + Drupal\emulsify_tools\ThemeGeneration\ThemeGeneratorInterface: + alias: Drupal\emulsify_tools\ThemeGeneration\EmulsifyThemeGenerator + Drupal\emulsify_tools\ThemeGeneration\ThemeMachineNameFactory: + arguments: ['@transliteration', '@extension.list.theme'] + Drupal\emulsify_tools\ThemeGeneration\EmulsifyThemeGenerator: + arguments: + - '@extension.list.theme' + - '@Drupal\emulsify_tools\ThemeGeneration\DrupalStarterkitThemeGenerator' + - !service_closure '@Drupal\emulsify_tools\ThemeGeneration\LegacyThemeGenerator' + - '%app.root%' + Drupal\emulsify_tools\ThemeGeneration\DrupalStarterkitThemeGenerator: + arguments: ['%app.root%'] + Drupal\emulsify_tools\ThemeGeneration\LegacyThemeGenerator: + arguments: + - '@extension.list.theme' + - '@Drupal\emulsify_tools\Archive\StarterRecipeArchiveExtractor' + - '@emulsify_tools.subtheme_generator' + - '@emulsify_tools.filesystem' + - '%app.root%' + deprecated: 'The "%service_id%" service is deprecated in emulsify_tools:2.2.0 and is removed from emulsify_tools:3.0.0. Use Drupal\emulsify_tools\ThemeGeneration\ThemeGeneratorInterface instead.' Drupal\emulsify_tools\Archive\StarterRecipeArchiveExtractor: class: Drupal\emulsify_tools\Archive\StarterRecipeArchiveExtractor autowire: true + deprecated: 'The "%service_id%" service is deprecated in emulsify_tools:2.2.0 and is removed from emulsify_tools:3.0.0. Use Drupal\emulsify_tools\ThemeGeneration\ThemeGeneratorInterface instead.' Drupal\emulsify_tools\Favicon\FaviconSourceSanitizerInterface: alias: emulsify_tools.favicon_source_sanitizer emulsify_tools.favicon_source_sanitizer: diff --git a/package.json b/package.json index 74ae040..740d7b4 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "emulsify_tools", "version": "2.1.1", - "description": "Toolset of useful Twig extensions, theme namespaces, and child theme generation for Emulsify", + "description": "Provides Twig extensions, theme Twig namespaces, child-theme generation, and favicon deployment commands for Emulsify.", "engines": { "node": ">=20.11" }, diff --git a/phpunit.xml.dist b/phpunit.xml.dist index b57d1b1..5bf9e66 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -11,6 +11,9 @@ tests/src/Unit + + tests/src/Integration + tests/src/Kernel diff --git a/src/Archive/StarterRecipeArchiveExtractor.php b/src/Archive/StarterRecipeArchiveExtractor.php index 49b8d7e..41bb245 100644 --- a/src/Archive/StarterRecipeArchiveExtractor.php +++ b/src/Archive/StarterRecipeArchiveExtractor.php @@ -9,6 +9,11 @@ /** * Extracts starter recipe archives without Drupal's deprecated archiver API. + * + * @deprecated in emulsify_tools:2.2.0 and is removed from + * emulsify_tools:3.0.0. Use + * \Drupal\emulsify_tools\ThemeGeneration\ThemeGeneratorInterface instead. + * @see https://www.drupal.org/project/drupal/issues/3364885 */ final class StarterRecipeArchiveExtractor { diff --git a/src/Drush/Commands/RepairFaviconConfigCommands.php b/src/Drush/Commands/RepairFaviconConfigCommands.php new file mode 100644 index 0000000..75c63f7 --- /dev/null +++ b/src/Drush/Commands/RepairFaviconConfigCommands.php @@ -0,0 +1,77 @@ +childThemeFaviconConfigRepairer->repair($theme); + } + catch (\InvalidArgumentException $exception) { + $this->logger()->error($exception->getMessage()); + return 1; + } + catch (\Throwable $exception) { + $this->logger()->error($exception->getMessage()); + return 1; + } + + foreach ($result['updated_themes'] as $themeName => $themeResult) { + $this->logger()->notice(sprintf( + 'Updated %s (%s): install=%s, schema=%s.', + $themeName, + $themeResult['path'], + $themeResult['install'], + $themeResult['schema'], + )); + } + + foreach ($result['errors'] as $themeName => $message) { + $this->logger()->error(sprintf('Unable to repair %s: %s', $themeName, $message)); + } + + if ($result['updated_count'] === 0 && $result['errors'] === []) { + $this->logger()->notice('No Emulsify child theme favicon source files needed repair.'); + } + + $this->logger()->notice(sprintf( + 'Inspected %d Emulsify-based child themes: %d updated, %d unchanged, %d errors.', + $result['inspected_count'], + $result['updated_count'], + $result['unchanged_count'], + count($result['errors']), + )); + + return $result['errors'] === [] ? 0 : 1; + } + +} diff --git a/src/Drush/Commands/SubThemeCommands.php b/src/Drush/Commands/SubThemeCommands.php index 15d3cbf..f4805a0 100644 --- a/src/Drush/Commands/SubThemeCommands.php +++ b/src/Drush/Commands/SubThemeCommands.php @@ -4,17 +4,13 @@ namespace Drupal\emulsify_tools\Drush\Commands; -use Drupal\Component\Utility\UrlHelper; -use Drupal\Core\Extension\ThemeExtensionList; -use Drupal\emulsify_tools\Archive\StarterRecipeArchiveExtractor; -use Drupal\emulsify_tools\Favicon\ChildThemeFaviconConfigRepairer; -use Drupal\emulsify_tools\SubThemeGenerator; +use Drupal\emulsify_tools\ThemeGeneration\ThemeGenerationRequest; +use Drupal\emulsify_tools\ThemeGeneration\ThemeGeneratorInterface; +use Drupal\emulsify_tools\ThemeGeneration\ThemeMachineName; +use Drupal\emulsify_tools\ThemeGeneration\ThemeMachineNameFactory; use Drush\Attributes as CLI; use Drush\Commands\AutowireTrait; use Drush\Commands\DrushCommands; -use Symfony\Component\DependencyInjection\Attribute\Autowire; -use Symfony\Component\Filesystem\Filesystem; -use Symfony\Component\Finder\Finder; /** * Provides Drush commands for Emulsify tools. @@ -23,397 +19,85 @@ final class SubThemeCommands extends DrushCommands { use AutowireTrait; - /** - * The Emulsify base theme machine name. - */ - private const EMULSIFY_THEME = 'emulsify'; - /** * Creates the command. */ public function __construct( - private readonly ThemeExtensionList $themeExtensionList, - private readonly StarterRecipeArchiveExtractor $starterRecipeArchiveExtractor, - #[Autowire(service: 'emulsify_tools.subtheme_generator')] - private readonly SubThemeGenerator $subThemeGenerator, - private readonly Filesystem $filesystem, - private readonly ChildThemeFaviconConfigRepairer $childThemeFaviconConfigRepairer, + private readonly ThemeMachineNameFactory $themeMachineNameFactory, + private readonly ThemeGeneratorInterface $themeGenerator, ) { parent::__construct(); } /** * Creates an Emulsify child theme. - */ - #[CLI\Command(name: 'emulsify_tools:bake', aliases: ['emulsify'])] - #[CLI\Argument(name: 'name', description: 'The name of your Emulsify-based child theme.')] - #[CLI\Usage(name: 'emulsify_tools:bake MyThemeName')] - public function generateSubTheme(string $name): int { - $machineName = $this->convertLabelToMachineName($name); - $this->logResolvedMachineName($name, $machineName); - - $sourceDirectory = $this->getStarterRecipeDirectory(); - $destinationDirectory = "themes/custom/{$machineName}"; - $state = ['srcDir' => $sourceDirectory]; - $temporaryDirectory = NULL; - - // The current Emulsify 7.x flow reads from the local whisk starter source, - // but the pipeline still supports archive URLs so alternate starter sources - // can reuse the same copy/extract/customize steps. - try { - if (UrlHelper::isValid($sourceDirectory, TRUE)) { - $temporaryDirectory = $this->createTemporaryDirectory(); - $state['path'] = $temporaryDirectory; - - if ($this->downloadStarterRecipe($state, $sourceDirectory) !== 0) { - return 1; - } - if ($this->extractStarterRecipe($state) !== 0) { - return 1; - } - } - - if ($this->copyStarterRecipe($state, $destinationDirectory) !== 0) { - return 1; - } - - return $this->customizeStarterRecipe($name, $machineName, $destinationDirectory); - } - finally { - if ($temporaryDirectory !== NULL && $this->filesystem->exists($temporaryDirectory)) { - $this->filesystem->remove($temporaryDirectory); - } - } - } - - /** - * Repairs child theme favicon install and schema files for Emulsify 7.x. - */ - #[CLI\Command(name: 'emulsify_tools:repair-favicon-config')] - #[CLI\Argument(name: 'theme', description: 'Optional Emulsify-based child theme machine name.')] - #[CLI\Usage(name: 'emulsify_tools:repair-favicon-config')] - #[CLI\Usage(name: 'emulsify_tools:repair-favicon-config my_child_theme')] - public function repairFaviconConfig(?string $theme = NULL): int { - try { - $result = $this->childThemeFaviconConfigRepairer->repair($theme); - } - catch (\InvalidArgumentException $exception) { - $this->logger()->error($exception->getMessage()); - return 1; - } - catch (\Throwable $exception) { - $this->logger()->error($exception->getMessage()); - return 1; - } - - foreach ($result['updated_themes'] as $themeName => $themeResult) { - $this->logger()->notice(sprintf( - 'Updated %s (%s): install=%s, schema=%s.', - $themeName, - $themeResult['path'], - $themeResult['install'], - $themeResult['schema'], - )); - } - - foreach ($result['errors'] as $themeName => $message) { - $this->logger()->error(sprintf('Unable to repair %s: %s', $themeName, $message)); - } - - if ($result['updated_count'] === 0 && $result['errors'] === []) { - $this->logger()->notice('No Emulsify child theme favicon source files needed repair.'); - } - - $this->logger()->notice(sprintf( - 'Inspected %d Emulsify-based child themes: %d updated, %d unchanged, %d errors.', - $result['inspected_count'], - $result['updated_count'], - $result['unchanged_count'], - count($result['errors']), - )); - - return $result['errors'] === [] ? 0 : 1; - } - - /** - * Convert label to machine name. * - * @param string $label - * The label. - * - * @return string - * The machine name. - */ - private function convertLabelToMachineName(string $label): string { - $machineName = preg_replace('/[^a-z0-9_]+/ui', '_', $label); - if ($machineName === NULL) { - throw new \RuntimeException(sprintf('Unable to convert "%s" to a machine name.', $label)); - } - - $machineName = trim(mb_strtolower($machineName), '_'); - if ($machineName === '') { - throw new \InvalidArgumentException('Theme name must contain at least one alphanumeric character.'); - } - - $this->validateMachineName($machineName, $label); - - return $machineName; - } - - /** - * Validates a Drupal theme machine name. + * @param string $name + * Positional machine name or label used to derive the machine name. + * @param array{name?: string|null, description?: string|null} $options + * Display-name and description options for the generated theme. * - * @throws \InvalidArgumentException - * Thrown when the machine name cannot be used for a child theme. - */ - private function validateMachineName(string $machineName, string $label): void { - if (preg_match('/^[a-z]/', $machineName) !== 1) { - throw new \InvalidArgumentException(sprintf( - 'Theme machine name "%s" derived from "%s" must start with a lowercase letter. Start the theme name with a letter, for example "my_theme".', - $machineName, - $label, - )); - } + * @return int + * The selected theme generator exit code. + */ + #[CLI\Command(name: 'emulsify_tools:bake', aliases: ['emulsify', 'emulsify_tools:generate-theme'])] + #[CLI\Help( + description: 'Generate an Emulsify child theme.', + synopsis: 'Pass a machine name or label as the positional value; use --name to set the human-readable display name.', + )] + #[CLI\Argument(name: 'name', description: 'Positional machine name or label used to derive the Drupal theme machine name.')] + #[CLI\Option(name: 'name', description: 'Human-readable display name for the generated theme. Defaults to the positional value.')] + #[CLI\Option(name: 'description', description: 'A description of the generated theme.')] + #[CLI\Usage(name: 'emulsify_tools:bake my_theme --name="My Theme" --description="Project theme"')] + #[CLI\Usage(name: 'emulsify_tools:generate-theme "My Theme"')] + public function generateSubTheme( + string $name, + array $options = ['name' => NULL, 'description' => ''], + ): int { + $resolvedName = $this->themeMachineNameFactory->create($name); + $this->logResolvedMachineName($resolvedName); + + $themeName = is_string($options['name'] ?? NULL) + ? $options['name'] + : $name; + $description = is_string($options['description'] ?? NULL) + ? $options['description'] + : ''; + + $result = $this->themeGenerator->generate(new ThemeGenerationRequest( + $resolvedName->machineName, + $themeName, + $description, + 'whisk', + 'themes/custom', + )); - if (strlen($machineName) > \DRUPAL_EXTENSION_NAME_MAX_LENGTH) { - throw new \InvalidArgumentException(sprintf( - 'Theme machine name "%s" is %d characters long, but Drupal theme machine names must be %d characters or fewer. Choose a shorter name.', - $machineName, - strlen($machineName), - \DRUPAL_EXTENSION_NAME_MAX_LENGTH, - )); + foreach ($result->warnings as $warning) { + $this->logger()->warning($warning); } - if ($machineName === self::EMULSIFY_THEME) { - throw new \InvalidArgumentException('Theme machine name "emulsify" is reserved by the Emulsify base theme. Choose a unique child theme name.'); + foreach ($result->messages as $message) { + $result->exitCode === 0 + ? $this->logger()->notice($message) + : $this->logger()->error($message); } - if (isset($this->themeExtensionList->getList()[$machineName])) { - throw new \InvalidArgumentException(sprintf( - 'Theme machine name "%s" is already used by an existing Drupal theme. Choose a unique child theme name.', - $machineName, - )); - } + return $result->exitCode; } /** * Logs when a human-readable label resolves to a different machine name. */ - private function logResolvedMachineName(string $name, string $machineName): void { - if ($machineName === trim($name)) { + private function logResolvedMachineName(ThemeMachineName $resolvedName): void { + if (!$resolvedName->wasNormalized()) { return; } $this->logger()->notice(sprintf( 'Using "%s" as the Drupal theme machine name for "%s".', - $machineName, - $name, + $resolvedName->machineName, + $resolvedName->originalInput, )); } - /** - * Resolves the Emulsify starter recipe directory. - * - * @return string - * The starter recipe directory. - */ - private function getStarterRecipeDirectory(): string { - $emulsifyDirectory = $this->themeExtensionList->getPath('emulsify'); - if ($emulsifyDirectory === '') { - throw new \RuntimeException('The Emulsify base theme could not be found.'); - } - - return $emulsifyDirectory . '/whisk'; - } - - /** - * Downloads a remote starter recipe archive. - * - * @param array $state - * The command state bag. - * @param string $sourceDirectory - * The remote archive URL. - * - * @return int - * Zero on success, non-zero on failure. - */ - private function downloadStarterRecipe(array &$state, string $sourceDirectory): int { - $this->logger()->debug( - 'download Emulsify recipe from {recipeUrl}', - ['recipeUrl' => $sourceDirectory], - ); - - $fileName = $this->getFileNameFromUrl($sourceDirectory); - $packageDirectory = "{$state['path']}/pack"; - $state['packPath'] = "{$packageDirectory}/{$fileName}"; - - try { - $this->filesystem->mkdir($packageDirectory); - $this->filesystem->copy($sourceDirectory, $state['packPath']); - } - catch (\Exception $exception) { - $this->logger()->error($exception->getMessage()); - return 1; - } - - return 0; - } - - /** - * Extracts a downloaded starter recipe archive. - * - * @param array $state - * The command state bag. - * - * @return int - * Zero on success, non-zero on failure. - */ - private function extractStarterRecipe(array &$state): int { - $this->logger()->debug( - 'extract downloaded Emulsify starter recipe from {packPath} to {srcDir}', - [ - 'packPath' => $state['packPath'], - 'srcDir' => "{$state['path']}/recipe", - ], - ); - - $state['srcDir'] = "{$state['path']}/recipe"; - - try { - $this->starterRecipeArchiveExtractor->extract($state['packPath'], $state['srcDir']); - } - catch (\Exception $exception) { - $this->logger()->error($exception->getMessage()); - return 1; - } - - $topLevelDirectory = $this->getTopLevelDirectory($state['srcDir']); - if ($topLevelDirectory !== '') { - $state['srcDir'] = $topLevelDirectory; - } - - return 0; - } - - /** - * Copies the starter recipe into the destination theme directory. - * - * @param array $state - * The command state bag. - * @param string $destinationDirectory - * The destination directory. - * - * @return int - * Zero on success, non-zero on failure. - */ - private function copyStarterRecipe(array $state, string $destinationDirectory): int { - $this->logger()->debug( - 'copy Emulsify starter recipe from {srcDir} to {dstDir}', - [ - 'srcDir' => $state['srcDir'], - 'dstDir' => $destinationDirectory, - ], - ); - - if ($this->filesystem->exists($destinationDirectory)) { - $this->logger()->error(sprintf('Destination directory "%s" already exists.', $destinationDirectory)); - return 1; - } - - try { - $this->filesystem->mirror($state['srcDir'], $destinationDirectory); - } - catch (\Exception $exception) { - $this->logger()->error($exception->getMessage()); - return 1; - } - - return 0; - } - - /** - * Creates a temporary working directory for starter recipe processing. - */ - private function createTemporaryDirectory(): string { - $temporaryDirectory = sys_get_temp_dir() . '/emulsify-tools-' . bin2hex(random_bytes(8)); - $this->filesystem->mkdir($temporaryDirectory); - - return $temporaryDirectory; - } - - /** - * Applies Emulsify-specific replacements to the copied starter recipe. - * - * @param string $name - * The theme label. - * @param string $machineName - * The theme machine name. - * @param string $destinationDirectory - * The copied destination directory. - * - * @return int - * Zero on success. - */ - private function customizeStarterRecipe(string $name, string $machineName, string $destinationDirectory): int { - $this->logger()->debug( - 'customize Emulsify starter recipe in {dstDir} directory', - ['dstDir' => $destinationDirectory], - ); - - $this->subThemeGenerator->generate($destinationDirectory, $machineName, $name); - - return 0; - } - - /** - * Get directory descendants. - * - * @return \Symfony\Component\Finder\Finder - * The finder. - */ - private function getDirectDescendants(string $dir): Finder { - return (new Finder()) - ->in($dir) - ->depth('== 0'); - } - - /** - * Get file name from URL. - * - * @param string $url - * The url. - * - * @return string - * The file name. - */ - private function getFileNameFromUrl(string $url): string { - $path = parse_url($url, PHP_URL_PATH); - return pathinfo(is_string($path) ? $path : '', PATHINFO_BASENAME); - } - - /** - * Get the top level dir. - * - * @param string $parentDir - * The parent directory. - * - * @return string - * The top level directory. - */ - private function getTopLevelDirectory(string $parentDir): string { - $directDescendants = $this->getDirectDescendants($parentDir); - if ($directDescendants->count() !== 1) { - return ''; - } - - $iterator = $directDescendants->getIterator(); - $iterator->rewind(); - $firstFile = $iterator->current(); - if ($firstFile->isDir()) { - return $firstFile->getPathname(); - } - - return ''; - } - } diff --git a/src/SubThemeGenerator.php b/src/SubThemeGenerator.php index 5210090..f110c89 100644 --- a/src/SubThemeGenerator.php +++ b/src/SubThemeGenerator.php @@ -4,11 +4,17 @@ namespace Drupal\emulsify_tools; +use Drupal\Component\Serialization\Yaml; use Symfony\Component\Filesystem\Filesystem; use Symfony\Component\Finder\Finder; /** * Generates Emulsify child themes. + * + * @deprecated in emulsify_tools:2.2.0 and is removed from + * emulsify_tools:3.0.0. Use + * \Drupal\emulsify_tools\ThemeGeneration\ThemeGeneratorInterface instead. + * @see https://www.drupal.org/project/drupal/issues/3364885 */ final class SubThemeGenerator { @@ -31,8 +37,15 @@ public function __construct( * The new machine name. * @param string $name * The new human-readable name. + * @param string|null $description + * The new description, or NULL to preserve the source description. */ - public function generate(string $directory, string $machineName, string $name): void { + public function generate( + string $directory, + string $machineName, + string $name, + ?string $description = NULL, + ): void { $originalMachineName = $this->discoverOriginalMachineName($directory); $this->removeStarterkitOnlyFiles($directory, $originalMachineName); @@ -45,8 +58,9 @@ public function generate(string $directory, string $machineName, string $name): // machine name still point at existing parent directories when files are // renamed afterward. $this->renameDirectories($directory, $originalMachineName, $machineName); - $this->renameFiles($directory, $originalMachineName, $machineName); } + $this->renameFiles($directory, $originalMachineName, $machineName); + $this->updateThemeInfo($directory, $machineName, $name, $description); } /** @@ -55,9 +69,11 @@ public function generate(string $directory, string $machineName, string $name): private function removeStarterkitOnlyFiles(string $directory, string $originalMachineName): void { $starterkitOnlyFiles = [ $directory . '/project.emulsify.json', - $directory . '/' . $originalMachineName . '.info.emulsify.yml', $directory . '/' . $originalMachineName . '.starterkit.yml', ]; + if ($this->filesystem->exists($directory . '/' . $originalMachineName . '.info.yml')) { + $starterkitOnlyFiles[] = $directory . '/' . $originalMachineName . '.info.emulsify.yml'; + } foreach ($starterkitOnlyFiles as $fileName) { if ($this->filesystem->exists($fileName)) { @@ -105,6 +121,9 @@ private function renameFiles(string $directory, string $originalMachineName, str if (str_contains($newFileName, '.emulsify.')) { $newFileName = str_replace('.emulsify.', '.', $newFileName); } + if ($newFileName === $fileName) { + continue; + } $this->filesystem->rename($fileName, $newFileName); } } @@ -214,10 +233,50 @@ private function getDirectoryNamesToRename(string $directory, string $originalMa private function getFileContentReplacementPairs(string $machineName, string $name): array { return [ 'EMULSIFY_NAME' => $name, + 'drupal:emulsify_tools (^4.0)' => 'drupal:emulsify_tools (^2.0)', + 'drupal:emulsify_tools (^1.0)' => 'drupal:emulsify_tools (^2.0)', 'whisk' => $machineName, ]; } + /** + * Safely applies the requested display metadata to the generated info file. + */ + private function updateThemeInfo( + string $directory, + string $machineName, + string $name, + ?string $description, + ): void { + $path = "{$directory}/{$machineName}.info.yml"; + $contents = $this->fileGetContents($path); + $values = ['name' => $name]; + if ($description !== NULL && $description !== '') { + $values['description'] = $description; + } + + foreach ($values as $key => $value) { + $encodedValue = rtrim(Yaml::encode($value), "\r\n"); + $replacement = "{$key}: {$encodedValue}"; + $count = 0; + $contents = preg_replace_callback( + '/^' . preg_quote($key, '/') . ':[^\r\n]*$/m', + static fn (): string => $replacement, + $contents, + -1, + $count, + ); + if ($contents === NULL) { + throw new \RuntimeException(sprintf('Unable to update theme metadata in "%s".', $path)); + } + if ($count === 0) { + $contents = rtrim($contents) . "\n{$replacement}\n"; + } + } + + $this->filesystem->dumpFile($path, $contents); + } + /** * Get files to make replacements. * diff --git a/src/ThemeGeneration/DrupalStarterkitThemeGenerator.php b/src/ThemeGeneration/DrupalStarterkitThemeGenerator.php new file mode 100644 index 0000000..278dff6 --- /dev/null +++ b/src/ThemeGeneration/DrupalStarterkitThemeGenerator.php @@ -0,0 +1,63 @@ + $request->machineName, + '--name' => $request->displayName, + '--description' => $request->description, + '--starterkit' => $request->starterkitMachineName, + '--path' => $request->destinationPath, + ]); + $input->setInteractive(FALSE); + $output = new BufferedOutput(); + $workingDirectory = getcwd(); + + try { + try { + $exitCode = (new GenerateTheme(NULL, $this->appRoot))->run($input, $output); + } + catch (\Throwable $exception) { + return new ThemeGenerationResult(1, [$exception->getMessage()]); + } + + $message = trim($output->fetch()); + + return new ThemeGenerationResult( + $exitCode, + $message === '' ? [] : [$message], + $exitCode === 0 + ? trim($request->destinationPath, '/') . '/' . $request->machineName + : NULL, + ); + } + finally { + if ($workingDirectory !== FALSE) { + chdir($workingDirectory); + } + } + } + +} diff --git a/src/ThemeGeneration/EmulsifyThemeGenerator.php b/src/ThemeGeneration/EmulsifyThemeGenerator.php new file mode 100644 index 0000000..3ad753e --- /dev/null +++ b/src/ThemeGeneration/EmulsifyThemeGenerator.php @@ -0,0 +1,135 @@ +legacyGeneratorFactory = $legacyGeneratorFactory; + } + + /** + * {@inheritdoc} + */ + public function generate(ThemeGenerationRequest $request): ThemeGenerationResult { + $baseThemeDirectory = $this->getBaseThemeDirectory(); + if ($baseThemeDirectory === NULL || !is_dir($baseThemeDirectory)) { + return new ThemeGenerationResult(1, [self::MISSING_BASE_THEME_MESSAGE]); + } + + $sourceDirectory = $baseThemeDirectory . '/' . $request->starterkitMachineName; + if (!is_dir($sourceDirectory)) { + return new ThemeGenerationResult(1, [self::MISSING_STARTERKIT_MESSAGE]); + } + + if ($this->isLegacySource($sourceDirectory, $request->starterkitMachineName)) { + return ($this->legacyGeneratorFactory)()->generate($request); + } + + if (!is_file("{$sourceDirectory}/{$request->starterkitMachineName}.starterkit.yml")) { + return new ThemeGenerationResult(1, [self::MISSING_STARTERKIT_CONFIG_MESSAGE]); + } + + // Modern parsing and generation failures belong to Drupal core. Never + // retry them through the legacy compatibility generator. + return $this->starterkitGenerator->generate($request); + } + + /** + * Resolves the installed Emulsify base-theme directory. + */ + private function getBaseThemeDirectory(): ?string { + try { + $emulsifyPath = $this->themeExtensionList->getPath('emulsify'); + } + catch (UnknownExtensionException) { + return NULL; + } + + return rtrim($this->appRoot, '/') . '/' . trim($emulsifyPath, '/'); + } + + /** + * Returns whether the source has the Emulsify Drupal 6.x legacy structure. + */ + private function isLegacySource(string $directory, string $machineName): bool { + if ( + is_file("{$directory}/{$machineName}.info.yml") + || is_file("{$directory}/{$machineName}.starterkit.yml") + ) { + return FALSE; + } + + $info = $this->decodeYamlFile("{$directory}/{$machineName}.info.emulsify.yml"); + + return $info !== NULL + && ($info['type'] ?? NULL) === 'theme' + && ($info['base theme'] ?? NULL) === 'emulsify'; + } + + /** + * Decodes a YAML mapping or returns NULL when it is unavailable or invalid. + * + * @return array|null + * The decoded mapping. + */ + private function decodeYamlFile(string $path): ?array { + if (!is_file($path)) { + return NULL; + } + + $contents = file_get_contents($path); + if ($contents === FALSE) { + return NULL; + } + + try { + $decoded = Yaml::decode($contents); + } + catch (\Throwable) { + return NULL; + } + + return is_array($decoded) ? $decoded : NULL; + } + +} diff --git a/src/ThemeGeneration/LegacyThemeGenerator.php b/src/ThemeGeneration/LegacyThemeGenerator.php new file mode 100644 index 0000000..c87e21e --- /dev/null +++ b/src/ThemeGeneration/LegacyThemeGenerator.php @@ -0,0 +1,144 @@ +getSourceDirectory($request->starterkitMachineName); + if (UrlHelper::isValid($sourceDirectory, TRUE)) { + $temporaryDirectory = $this->createTemporaryDirectory(); + $sourceDirectory = $this->extractRemoteSource($sourceDirectory, $temporaryDirectory); + } + else { + $relativeSourceDirectory = $sourceDirectory; + $sourceDirectory = rtrim($this->appRoot, '/') . '/' . ltrim($sourceDirectory, '/'); + if (!is_dir($sourceDirectory)) { + throw new \RuntimeException(sprintf( + 'The Emulsify Whisk source directory was not found: %s', + $relativeSourceDirectory, + )); + } + } + + $destinationPath = trim($request->destinationPath, '/') . '/' . $request->machineName; + $destinationDirectory = rtrim($this->appRoot, '/') . '/' . $destinationPath; + if ($this->filesystem->exists($destinationDirectory)) { + return new ThemeGenerationResult( + 1, + [sprintf('The destination theme already exists: %s', $destinationPath)], + warnings: $warnings, + ); + } + + $this->filesystem->mirror($sourceDirectory, $destinationDirectory); + $this->subThemeGenerator->generate( + $destinationDirectory, + $request->machineName, + $request->displayName, + $request->description, + ); + + return new ThemeGenerationResult(0, [], $destinationPath, $warnings); + } + catch (\Throwable $exception) { + return new ThemeGenerationResult(1, [$exception->getMessage()], warnings: $warnings); + } + finally { + if ($temporaryDirectory !== NULL && $this->filesystem->exists($temporaryDirectory)) { + $this->filesystem->remove($temporaryDirectory); + } + } + } + + /** + * Resolves the Emulsify Whisk source path. + */ + private function getSourceDirectory(string $starterkitMachineName): string { + try { + $emulsifyPath = $this->themeExtensionList->getPath('emulsify'); + } + catch (UnknownExtensionException $exception) { + throw new \RuntimeException('The Emulsify parent theme was not found: emulsify', 0, $exception); + } + + return rtrim($emulsifyPath, '/') . '/' . $starterkitMachineName; + } + + /** + * Downloads and extracts a remote legacy source archive. + */ + private function extractRemoteSource(string $source, string $temporaryDirectory): string { + $path = parse_url($source, PHP_URL_PATH); + $fileName = pathinfo(is_string($path) ? $path : '', PATHINFO_BASENAME); + $archivePath = "{$temporaryDirectory}/pack/{$fileName}"; + $recipeDirectory = "{$temporaryDirectory}/recipe"; + + $this->filesystem->mkdir(dirname($archivePath)); + $this->filesystem->copy($source, $archivePath); + $this->starterRecipeArchiveExtractor->extract($archivePath, $recipeDirectory); + + $directDescendants = (new Finder())->in($recipeDirectory)->depth('== 0'); + if ($directDescendants->count() !== 1) { + return $recipeDirectory; + } + + $iterator = $directDescendants->getIterator(); + $iterator->rewind(); + $firstEntry = $iterator->current(); + + return $firstEntry->isDir() ? $firstEntry->getPathname() : $recipeDirectory; + } + + /** + * Creates a temporary directory for remote legacy sources. + */ + private function createTemporaryDirectory(): string { + $temporaryDirectory = sys_get_temp_dir() . '/emulsify-tools-' . bin2hex(random_bytes(8)); + $this->filesystem->mkdir($temporaryDirectory); + + return $temporaryDirectory; + } + +} diff --git a/src/ThemeGeneration/ThemeGenerationRequest.php b/src/ThemeGeneration/ThemeGenerationRequest.php new file mode 100644 index 0000000..ffb9243 --- /dev/null +++ b/src/ThemeGeneration/ThemeGenerationRequest.php @@ -0,0 +1,23 @@ + $messages + * User-facing messages produced while generating the theme. + * @param string|null $destinationPath + * The generated theme path when available. + * @param list $warnings + * User-facing warnings produced while generating the theme. + */ + public function __construct( + public int $exitCode, + public array $messages, + public ?string $destinationPath = NULL, + public array $warnings = [], + ) {} + +} diff --git a/src/ThemeGeneration/ThemeGeneratorInterface.php b/src/ThemeGeneration/ThemeGeneratorInterface.php new file mode 100644 index 0000000..6e630e3 --- /dev/null +++ b/src/ThemeGeneration/ThemeGeneratorInterface.php @@ -0,0 +1,17 @@ +machineName !== trim($this->originalInput); + } + +} diff --git a/src/ThemeGeneration/ThemeMachineNameFactory.php b/src/ThemeGeneration/ThemeMachineNameFactory.php new file mode 100644 index 0000000..cc8162a --- /dev/null +++ b/src/ThemeGeneration/ThemeMachineNameFactory.php @@ -0,0 +1,86 @@ +transliteration->transliterate( + trim($input), + LanguageInterface::LANGCODE_DEFAULT, + '_', + ); + $machineName = preg_replace('/[^a-z0-9_]+/', '_', strtolower($transliterated)); + if ($machineName === NULL) { + throw new \RuntimeException(sprintf('Unable to convert "%s" to a machine name.', $input)); + } + + $collapsedName = preg_replace('/_+/', '_', $machineName); + if ($collapsedName === NULL) { + throw new \RuntimeException(sprintf('Unable to convert "%s" to a machine name.', $input)); + } + $machineName = trim($collapsedName, '_'); + if (preg_match('/[a-z0-9]/', $machineName) !== 1) { + throw new \InvalidArgumentException(sprintf( + 'Theme name "%s" could not be converted to a valid Drupal machine name. Enter a name containing letters or numbers, for example "My Project Theme".', + $input, + )); + } + + if (preg_match('/^[a-z]/', $machineName) !== 1) { + throw new \InvalidArgumentException(sprintf( + 'Theme machine name "%s" derived from "%s" must start with a lowercase letter. Start the theme name with a letter, for example "my_theme".', + $machineName, + $input, + )); + } + + if (strlen($machineName) > \DRUPAL_EXTENSION_NAME_MAX_LENGTH) { + throw new \InvalidArgumentException(sprintf( + 'Theme machine name "%s" is %d characters long, but Drupal theme machine names must be %d characters or fewer. Choose a shorter name, for example "my_project_theme".', + $machineName, + strlen($machineName), + \DRUPAL_EXTENSION_NAME_MAX_LENGTH, + )); + } + + if ($machineName === self::EMULSIFY_THEME) { + throw new \InvalidArgumentException('Theme machine name "emulsify" is reserved by the Emulsify base theme. Choose a unique child theme name, for example "my_project_theme".'); + } + + if ($this->themeExtensionList->exists($machineName)) { + throw new \InvalidArgumentException(sprintf( + 'Theme machine name "%s" is already used by an existing Drupal theme. Choose a unique child theme name, for example "my_project_theme".', + $machineName, + )); + } + + return new ThemeMachineName($input, $machineName); + } + +} diff --git a/tests/fixtures/ThemeGeneration/emulsify-6/whisk/components/whisk-section/whisk-card.twig b/tests/fixtures/ThemeGeneration/emulsify-6/whisk/components/whisk-section/whisk-card.twig new file mode 100644 index 0000000..f4654cd --- /dev/null +++ b/tests/fixtures/ThemeGeneration/emulsify-6/whisk/components/whisk-section/whisk-card.twig @@ -0,0 +1 @@ +Theme machine: whisk diff --git a/tests/fixtures/ThemeGeneration/emulsify-6/whisk/config/install/whisk.settings.yml b/tests/fixtures/ThemeGeneration/emulsify-6/whisk/config/install/whisk.settings.yml new file mode 100644 index 0000000..32f4b0f --- /dev/null +++ b/tests/fixtures/ThemeGeneration/emulsify-6/whisk/config/install/whisk.settings.yml @@ -0,0 +1 @@ +# Legacy Emulsify 6.x child theme settings overrides. diff --git a/tests/fixtures/ThemeGeneration/emulsify-6/whisk/config/schema/whisk.schema.yml b/tests/fixtures/ThemeGeneration/emulsify-6/whisk/config/schema/whisk.schema.yml new file mode 100644 index 0000000..f10962e --- /dev/null +++ b/tests/fixtures/ThemeGeneration/emulsify-6/whisk/config/schema/whisk.schema.yml @@ -0,0 +1,3 @@ +whisk.settings: + type: theme_settings + label: 'whisk settings' diff --git a/tests/fixtures/ThemeGeneration/emulsify-6/whisk/project.emulsify.json b/tests/fixtures/ThemeGeneration/emulsify-6/whisk/project.emulsify.json new file mode 100644 index 0000000..7245732 --- /dev/null +++ b/tests/fixtures/ThemeGeneration/emulsify-6/whisk/project.emulsify.json @@ -0,0 +1,11 @@ +{ + "project": { + "platform": "drupal", + "name": "whisk", + "machineName": "whisk", + "singleDirectoryComponents": true + }, + "starter": { + "repository": "https://github.com/emulsify-ds/emulsify-drupal.git" + } +} diff --git a/tests/fixtures/ThemeGeneration/emulsify-6/whisk/whisk.info.emulsify.yml b/tests/fixtures/ThemeGeneration/emulsify-6/whisk/whisk.info.emulsify.yml new file mode 100644 index 0000000..7e12d8f --- /dev/null +++ b/tests/fixtures/ThemeGeneration/emulsify-6/whisk/whisk.info.emulsify.yml @@ -0,0 +1,16 @@ +type: theme +base theme: emulsify +core_version_requirement: '^10.3 || ^11' + +name: whisk +description: A subtheme build upon the Emulsify Design System. +package: 'Emulsify' +version: VERSION +hidden: false + +dependencies: + - 'drupal:components (^3.0)' + - 'drupal:emulsify_tools (^1.0)' + +libraries: + - whisk/global diff --git a/tests/fixtures/ThemeGeneration/emulsify-7/whisk/README.md b/tests/fixtures/ThemeGeneration/emulsify-7/whisk/README.md new file mode 100644 index 0000000..0428dbe --- /dev/null +++ b/tests/fixtures/ThemeGeneration/emulsify-7/whisk/README.md @@ -0,0 +1 @@ +whisk starter fixture. diff --git a/tests/fixtures/ThemeGeneration/emulsify-7/whisk/config/install/whisk.settings.yml b/tests/fixtures/ThemeGeneration/emulsify-7/whisk/config/install/whisk.settings.yml new file mode 100644 index 0000000..75f456f --- /dev/null +++ b/tests/fixtures/ThemeGeneration/emulsify-7/whisk/config/install/whisk.settings.yml @@ -0,0 +1 @@ +favicon_source_filename: whisk.svg diff --git a/tests/fixtures/ThemeGeneration/emulsify-7/whisk/config/schema/whisk.schema.yml b/tests/fixtures/ThemeGeneration/emulsify-7/whisk/config/schema/whisk.schema.yml new file mode 100644 index 0000000..e8729f1 --- /dev/null +++ b/tests/fixtures/ThemeGeneration/emulsify-7/whisk/config/schema/whisk.schema.yml @@ -0,0 +1,3 @@ +whisk.settings: + type: config_object + label: 'whisk settings' diff --git a/tests/fixtures/ThemeGeneration/emulsify-7/whisk/project.emulsify.json b/tests/fixtures/ThemeGeneration/emulsify-7/whisk/project.emulsify.json new file mode 100644 index 0000000..7245732 --- /dev/null +++ b/tests/fixtures/ThemeGeneration/emulsify-7/whisk/project.emulsify.json @@ -0,0 +1,11 @@ +{ + "project": { + "platform": "drupal", + "name": "whisk", + "machineName": "whisk", + "singleDirectoryComponents": true + }, + "starter": { + "repository": "https://github.com/emulsify-ds/emulsify-drupal.git" + } +} diff --git a/tests/fixtures/ThemeGeneration/emulsify-7/whisk/whisk.info.emulsify.yml b/tests/fixtures/ThemeGeneration/emulsify-7/whisk/whisk.info.emulsify.yml new file mode 100644 index 0000000..f8d9039 --- /dev/null +++ b/tests/fixtures/ThemeGeneration/emulsify-7/whisk/whisk.info.emulsify.yml @@ -0,0 +1,3 @@ +name: whisk +type: theme +base theme: emulsify diff --git a/tests/fixtures/ThemeGeneration/emulsify-7/whisk/whisk.info.yml b/tests/fixtures/ThemeGeneration/emulsify-7/whisk/whisk.info.yml new file mode 100644 index 0000000..fd879e1 --- /dev/null +++ b/tests/fixtures/ThemeGeneration/emulsify-7/whisk/whisk.info.yml @@ -0,0 +1,13 @@ +# phpcs:ignoreFile -- Standalone fixture requires a stable, un-packaged version. +name: Whisk Starter +type: theme +base theme: emulsify +core_version_requirement: '^11.3 || ^12' +description: Generation-only starter source for Emulsify child themes. +package: Emulsify +version: 1.0.0 +hidden: true +dependencies: + - 'drupal:emulsify_tools (^2.0)' +libraries: + - whisk/global diff --git a/tests/fixtures/ThemeGeneration/emulsify-7/whisk/whisk.starterkit.yml b/tests/fixtures/ThemeGeneration/emulsify-7/whisk/whisk.starterkit.yml new file mode 100644 index 0000000..eef85fa --- /dev/null +++ b/tests/fixtures/ThemeGeneration/emulsify-7/whisk/whisk.starterkit.yml @@ -0,0 +1,6 @@ +ignore: + - '/whisk.info.emulsify.yml' + - '/whisk.starterkit.yml' +info: + core_version_requirement: '^11.3 || ^12' + hidden: null diff --git a/tests/src/Unit/ChildThemeFaviconConfigRepairerTest.php b/tests/src/Integration/ChildThemeFaviconConfigRepairerTest.php similarity index 98% rename from tests/src/Unit/ChildThemeFaviconConfigRepairerTest.php rename to tests/src/Integration/ChildThemeFaviconConfigRepairerTest.php index 11c3fe6..f851351 100644 --- a/tests/src/Unit/ChildThemeFaviconConfigRepairerTest.php +++ b/tests/src/Integration/ChildThemeFaviconConfigRepairerTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Drupal\Tests\emulsify_tools\Unit; +namespace Drupal\Tests\emulsify_tools\Integration; use Drupal\Component\Serialization\Yaml; use Drupal\Core\Extension\Extension; diff --git a/tests/src/Integration/RepairFaviconConfigCommandsTest.php b/tests/src/Integration/RepairFaviconConfigCommandsTest.php new file mode 100644 index 0000000..fba90ba --- /dev/null +++ b/tests/src/Integration/RepairFaviconConfigCommandsTest.php @@ -0,0 +1,137 @@ +getAttributes(Command::class); + self::assertCount(1, $commands); + self::assertSame('emulsify_tools:repair-favicon-config', $commands[0]->newInstance()->name); + + $arguments = $method->getAttributes(Argument::class); + self::assertCount(1, $arguments); + self::assertSame('theme', $arguments[0]->newInstance()->name); + + $usages = array_map( + static fn (\ReflectionAttribute $attribute): string => $attribute->newInstance()->name, + $method->getAttributes(Usage::class), + ); + self::assertSame([ + 'emulsify_tools:repair-favicon-config', + 'emulsify_tools:repair-favicon-config my_child_theme', + ], $usages); + } + + /** + * Tests a no-op repair reports success and its unchanged summary. + */ + public function testRepairReportsNoEligibleThemes(): void { + $logger = new RepairFaviconConfigCommandRecordingLogger(); + $command = $this->createCommand($logger); + + self::assertSame(0, $command->repairFaviconConfig()); + self::assertTrue($logger->hasRecordContaining( + LogLevel::NOTICE, + 'No Emulsify child theme favicon source files needed repair.', + )); + self::assertTrue($logger->hasRecordContaining( + LogLevel::NOTICE, + 'Inspected 0 Emulsify-based child themes: 0 updated, 0 unchanged, 0 errors.', + )); + } + + /** + * Tests an ineligible requested theme reports the repair error. + */ + public function testRepairRejectsIneligibleRequestedTheme(): void { + $logger = new RepairFaviconConfigCommandRecordingLogger(); + $command = $this->createCommand($logger); + + self::assertSame(1, $command->repairFaviconConfig('olivero')); + self::assertTrue($logger->hasRecordContaining( + LogLevel::ERROR, + 'Theme "olivero" is not an Emulsify-based child theme in this codebase.', + )); + } + + /** + * Creates the command under test. + */ + private function createCommand(RepairFaviconConfigCommandRecordingLogger $logger): RepairFaviconConfigCommands { + $themeExtensionList = $this->createMock(ThemeExtensionList::class); + $themeExtensionList->method('getList')->willReturn([]); + + $command = new RepairFaviconConfigCommands(new ChildThemeFaviconConfigRepairer( + sys_get_temp_dir(), + $themeExtensionList, + new Filesystem(), + )); + $command->setLogger($logger); + + return $command; + } + +} + +/** + * Records repair command log messages. + */ +final class RepairFaviconConfigCommandRecordingLogger extends AbstractLogger { + + /** + * Recorded log entries. + * + * @var list + */ + private array $records = []; + + /** + * {@inheritdoc} + */ + public function log($level, \Stringable|string $message, array $context = []): void { + $this->records[] = [ + 'level' => $level, + 'message' => (string) $message, + ]; + } + + /** + * Returns whether a record at the requested level contains a fragment. + */ + public function hasRecordContaining(string $level, string $fragment): bool { + foreach ($this->records as $record) { + if ($record['level'] === $level && str_contains($record['message'], $fragment)) { + return TRUE; + } + } + + return FALSE; + } + +} diff --git a/tests/src/Unit/StarterRecipeArchiveExtractorTest.php b/tests/src/Integration/StarterRecipeArchiveExtractorTest.php similarity index 98% rename from tests/src/Unit/StarterRecipeArchiveExtractorTest.php rename to tests/src/Integration/StarterRecipeArchiveExtractorTest.php index 7308598..8ef3c85 100644 --- a/tests/src/Unit/StarterRecipeArchiveExtractorTest.php +++ b/tests/src/Integration/StarterRecipeArchiveExtractorTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Drupal\Tests\emulsify_tools\Unit; +namespace Drupal\Tests\emulsify_tools\Integration; use Drupal\Core\Archiver\ArchiveTar; use Drupal\emulsify_tools\Archive\StarterRecipeArchiveExtractor; diff --git a/tests/src/Integration/SubThemeGeneratorTest.php b/tests/src/Integration/SubThemeGeneratorTest.php new file mode 100644 index 0000000..3d8e64d --- /dev/null +++ b/tests/src/Integration/SubThemeGeneratorTest.php @@ -0,0 +1,186 @@ +filesystem = new Filesystem(); + $this->temporaryDirectory = sys_get_temp_dir() . '/emulsify_tools_' . bin2hex(random_bytes(8)); + $this->filesystem->mkdir($this->temporaryDirectory); + } + + /** + * {@inheritdoc} + */ + protected function tearDown(): void { + if (isset($this->temporaryDirectory) && $this->filesystem->exists($this->temporaryDirectory)) { + $this->filesystem->remove($this->temporaryDirectory); + } + + parent::tearDown(); + } + + /** + * Tests file, directory, and content replacements during generation. + */ + public function testGenerateCustomizesLegacyWhiskFixture(): void { + $fixtureDirectory = dirname(__DIR__, 2) . '/fixtures/ThemeGeneration/emulsify-6/whisk'; + $themeDirectory = $this->temporaryDirectory . '/theme'; + $displayName = "Child's Theme: News & Events"; + $description = "Built for editors: fast, focused & accessible."; + + self::assertFileDoesNotExist($fixtureDirectory . '/whisk.info.yml'); + self::assertFileDoesNotExist($fixtureDirectory . '/whisk.starterkit.yml'); + $this->filesystem->mirror($fixtureDirectory, $themeDirectory); + + $generator = new SubThemeGenerator($this->filesystem); + $generator->generate($themeDirectory, 'child', $displayName, $description); + + self::assertFileDoesNotExist($themeDirectory . '/whisk.info.emulsify.yml'); + self::assertFileDoesNotExist($themeDirectory . '/project.emulsify.json'); + self::assertFileExists($themeDirectory . '/child.info.yml'); + + $info = Yaml::decode($this->readFile($themeDirectory . '/child.info.yml')); + self::assertIsArray($info); + self::assertSame($displayName, $info['name']); + self::assertSame($description, $info['description']); + self::assertSame([ + 'drupal:components (^3.0)', + 'drupal:emulsify_tools (^2.0)', + ], $info['dependencies']); + + self::assertFileExists($themeDirectory . '/config/install/child.settings.yml'); + self::assertFileExists($themeDirectory . '/config/schema/child.schema.yml'); + self::assertDirectoryDoesNotExist($themeDirectory . '/components/whisk-section'); + self::assertDirectoryExists($themeDirectory . '/components/child-section'); + self::assertFileDoesNotExist($themeDirectory . '/components/child-section/whisk-card.twig'); + self::assertFileExists($themeDirectory . '/components/child-section/child-card.twig'); + self::assertSame( + "Theme machine: child\n", + $this->readFile($themeDirectory . '/components/child-section/child-card.twig'), + ); + } + + /** + * Tests an empty description preserves legacy metadata and normalizes info. + */ + public function testGeneratePreservesLegacyDescriptionForWhiskMachineName(): void { + $fixtureDirectory = dirname(__DIR__, 2) . '/fixtures/ThemeGeneration/emulsify-6/whisk'; + $themeDirectory = $this->temporaryDirectory . '/whisk-theme'; + $this->filesystem->mirror($fixtureDirectory, $themeDirectory); + + $generator = new SubThemeGenerator($this->filesystem); + $generator->generate($themeDirectory, 'whisk', 'Whisk Project', ''); + + self::assertFileDoesNotExist($themeDirectory . '/whisk.info.emulsify.yml'); + self::assertFileExists($themeDirectory . '/whisk.info.yml'); + $info = Yaml::decode($this->readFile($themeDirectory . '/whisk.info.yml')); + self::assertIsArray($info); + self::assertSame('Whisk Project', $info['name']); + self::assertSame('A subtheme build upon the Emulsify Design System.', $info['description']); + } + + /** + * Tests direct callers retain modern starter-only metadata cleanup. + */ + public function testGenerateRemovesModernStarterkitMetadata(): void { + $themeDirectory = $this->temporaryDirectory . '/modern-source'; + $this->filesystem->mkdir($themeDirectory); + $this->filesystem->dumpFile($themeDirectory . '/whisk.info.yml', "name: EMULSIFY_NAME\ntype: theme\n"); + $this->filesystem->dumpFile($themeDirectory . '/whisk.info.emulsify.yml', "name: whisk\ntype: theme\n"); + $this->filesystem->dumpFile($themeDirectory . '/whisk.starterkit.yml', "info: {}\n"); + $this->filesystem->dumpFile($themeDirectory . '/project.emulsify.json', "{}\n"); + + $generator = new SubThemeGenerator($this->filesystem); + $generator->generate($themeDirectory, 'child', 'Child Theme'); + + self::assertFileDoesNotExist($themeDirectory . '/whisk.info.emulsify.yml'); + self::assertFileDoesNotExist($themeDirectory . '/whisk.starterkit.yml'); + self::assertFileDoesNotExist($themeDirectory . '/project.emulsify.json'); + self::assertFileExists($themeDirectory . '/child.info.yml'); + $info = Yaml::decode($this->readFile($themeDirectory . '/child.info.yml')); + self::assertIsArray($info); + self::assertSame('Child Theme', $info['name']); + } + + /** + * Tests Emulsify Drupal 6.0 and 6.1 dependency metadata is updated. + */ + public function testGenerateUpdatesEarlyLegacyToolsDependency(): void { + $fixtureDirectory = dirname(__DIR__, 2) . '/fixtures/ThemeGeneration/emulsify-6/whisk'; + $themeDirectory = $this->temporaryDirectory . '/early-legacy'; + $this->filesystem->mirror($fixtureDirectory, $themeDirectory); + $infoPath = $themeDirectory . '/whisk.info.emulsify.yml'; + $this->filesystem->dumpFile( + $infoPath, + str_replace('(^1.0)', '(^4.0)', $this->readFile($infoPath)), + ); + + $generator = new SubThemeGenerator($this->filesystem); + $generator->generate($themeDirectory, 'early_child', 'Early Child'); + + $info = Yaml::decode($this->readFile($themeDirectory . '/early_child.info.yml')); + self::assertIsArray($info); + self::assertSame([ + 'drupal:components (^3.0)', + 'drupal:emulsify_tools (^2.0)', + ], $info['dependencies']); + } + + /** + * Tests that the source theme info file is required. + */ + public function testGenerateRequiresAnEmulsifyInfoFile(): void { + $themeDirectory = $this->temporaryDirectory . '/missing-info'; + $this->filesystem->mkdir($themeDirectory); + + $generator = new SubThemeGenerator($this->filesystem); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage(sprintf('No *.info.emulsify.yml file was found in "%s".', $themeDirectory)); + $generator->generate($themeDirectory, 'child', 'Child Theme', 'Description'); + } + + /** + * Reads a generated file. + */ + private function readFile(string $path): string { + $contents = file_get_contents($path); + if ($contents === FALSE) { + throw new \RuntimeException(sprintf('Failed to read fixture file "%s".', $path)); + } + + return $contents; + } + +} diff --git a/tests/src/Integration/ThemeGeneration/DrupalStarterkitThemeGeneratorTest.php b/tests/src/Integration/ThemeGeneration/DrupalStarterkitThemeGeneratorTest.php new file mode 100644 index 0000000..f0993af --- /dev/null +++ b/tests/src/Integration/ThemeGeneration/DrupalStarterkitThemeGeneratorTest.php @@ -0,0 +1,302 @@ +filesystem = new Filesystem(); + $originalWorkingDirectory = getcwd(); + if ($originalWorkingDirectory === FALSE) { + throw new \RuntimeException('Unable to determine the test working directory.'); + } + $this->originalWorkingDirectory = $originalWorkingDirectory; + + $this->temporaryDirectory = sys_get_temp_dir() . '/emulsify_tools_generator_' . bin2hex(random_bytes(8)); + $this->appRoot = $this->temporaryDirectory . '/app-root'; + $callerDirectory = $this->temporaryDirectory . '/caller'; + $this->filesystem->mkdir([$this->appRoot, $callerDirectory]); + + $resolvedCallerDirectory = realpath($callerDirectory); + if ($resolvedCallerDirectory === FALSE || !chdir($resolvedCallerDirectory)) { + throw new \RuntimeException('Unable to enter the test caller directory.'); + } + $this->callerDirectory = $resolvedCallerDirectory; + } + + /** + * {@inheritdoc} + */ + protected function tearDown(): void { + if (isset($this->originalWorkingDirectory)) { + chdir($this->originalWorkingDirectory); + } + if (isset($this->temporaryDirectory) && $this->filesystem->exists($this->temporaryDirectory)) { + $this->filesystem->remove($this->temporaryDirectory); + } + + parent::tearDown(); + } + + /** + * Tests one real core generation through the adapter. + */ + public function testGenerateCreatesExpectedThemeAndRestoresWorkingDirectory(): void { + $this->writeStarterRecipe(); + + $result = $this->generator()->generate($this->createRequest()); + + self::assertSame($this->callerDirectory, getcwd()); + self::assertSame(0, $result->exitCode); + self::assertSame('themes/custom/happy_theme', $result->destinationPath); + self::assertStringContainsString( + 'Theme generated successfully to themes/custom/happy_theme', + implode("\n", $result->messages), + ); + + $destination = $this->appRoot . '/themes/custom/happy_theme'; + self::assertDirectoryExists($destination); + self::assertFileExists($destination . '/happy_theme.info.yml'); + self::assertFileDoesNotExist($destination . '/whisk.info.yml'); + self::assertFileDoesNotExist($destination . '/whisk.info.emulsify.yml'); + self::assertFileDoesNotExist($destination . '/whisk.starterkit.yml'); + + $info = $this->readYaml($destination . '/happy_theme.info.yml'); + self::assertSame('Happy Theme', $info['name']); + self::assertSame('Project theme: punctuation & metadata.', $info['description']); + self::assertSame('emulsify', $info['base theme']); + self::assertSame('^11.3 || ^12', $info['core_version_requirement']); + self::assertSame('1.0.0', $info['version']); + self::assertSame('whisk:1.0.0', $info['generator']); + self::assertSame(['drupal:emulsify_tools (^2.0)'], $info['dependencies']); + self::assertSame(['happy_theme/global'], $info['libraries']); + self::assertArrayNotHasKey('hidden', $info); + + self::assertFileExists($destination . '/config/install/happy_theme.settings.yml'); + self::assertFileDoesNotExist($destination . '/config/install/whisk.settings.yml'); + self::assertSame([ + 'favicon_source_filename' => 'happy_theme.svg', + ], $this->readYaml($destination . '/config/install/happy_theme.settings.yml')); + + self::assertFileExists($destination . '/config/schema/happy_theme.schema.yml'); + self::assertFileDoesNotExist($destination . '/config/schema/whisk.schema.yml'); + self::assertSame([ + 'happy_theme.settings' => [ + 'type' => 'config_object', + 'label' => 'happy_theme settings', + ], + ], $this->readYaml($destination . '/config/schema/happy_theme.schema.yml')); + + self::assertSame([ + 'project' => [ + 'platform' => 'drupal', + 'name' => 'happy_theme', + 'machineName' => 'happy_theme', + 'singleDirectoryComponents' => TRUE, + ], + 'starter' => [ + 'repository' => 'https://github.com/emulsify-ds/emulsify-drupal.git', + ], + ], $this->readJson($destination . '/project.emulsify.json')); + self::assertSame( + "happy_theme starter fixture.\n", + $this->readFile($destination . '/README.md'), + ); + } + + /** + * Tests an existing destination is preserved after a nonzero core exit. + */ + public function testGeneratePreservesExistingDestinationAndRestoresWorkingDirectory(): void { + $this->writeStarterRecipe(); + $destination = $this->appRoot . '/themes/custom/happy_theme'; + $this->filesystem->mkdir($destination); + $this->filesystem->dumpFile($destination . '/sentinel.txt', "do not replace\n"); + + $result = $this->generator()->generate($this->createRequest()); + + self::assertSame($this->callerDirectory, getcwd()); + self::assertSame(1, $result->exitCode); + self::assertNull($result->destinationPath); + $message = implode("\n", $result->messages); + self::assertStringContainsString('Theme could not be generated because the destination directory', $message); + self::assertStringContainsString('themes/custom/happy_theme exists already.', $message); + self::assertSame(['sentinel.txt'], $this->readDirectoryEntries($destination)); + self::assertSame("do not replace\n", $this->readFile($destination . '/sentinel.txt')); + } + + /** + * Tests core exceptions become results and restore the working directory. + */ + public function testGenerateTranslatesExceptionAndRestoresWorkingDirectory(): void { + $this->writeStarterRecipe(); + $this->filesystem->dumpFile( + $this->appRoot . '/themes/contrib/emulsify/whisk/whisk.info.yml', + "type: theme\ncore_version_requirement: '^11.3 || ^12'\n", + ); + + $result = $this->generator()->generate($this->createRequest()); + + self::assertSame($this->callerDirectory, getcwd()); + self::assertSame(1, $result->exitCode); + self::assertNull($result->destinationPath); + self::assertStringContainsString('Missing required keys (name)', implode("\n", $result->messages)); + self::assertDirectoryDoesNotExist($this->appRoot . '/themes/custom/happy_theme'); + } + + /** + * Tests missing Whisk source behavior through Drupal core. + */ + public function testGenerateReportsMissingWhiskSource(): void { + $result = $this->generator()->generate($this->createRequest()); + + self::assertSame($this->callerDirectory, getcwd()); + self::assertSame(1, $result->exitCode); + self::assertNull($result->destinationPath); + self::assertStringContainsString( + 'Theme source theme whisk cannot be found.', + implode("\n", $result->messages), + ); + self::assertDirectoryDoesNotExist($this->appRoot . '/themes/custom/happy_theme'); + } + + /** + * Creates the adapter under test. + */ + private function generator(): DrupalStarterkitThemeGenerator { + return new DrupalStarterkitThemeGenerator($this->appRoot); + } + + /** + * Creates the generation request used by the integration scenarios. + */ + private function createRequest(): ThemeGenerationRequest { + return new ThemeGenerationRequest( + machineName: 'happy_theme', + displayName: 'Happy Theme', + description: 'Project theme: punctuation & metadata.', + starterkitMachineName: 'whisk', + destinationPath: 'themes/custom', + ); + } + + /** + * Copies the checked-in Emulsify 7 Whisk fixture into the app root. + */ + private function writeStarterRecipe(): void { + $source = dirname(__DIR__, 3) . '/fixtures/ThemeGeneration/emulsify-7/whisk'; + if (!is_dir($source)) { + throw new \RuntimeException(sprintf('Whisk fixture not found at "%s".', $source)); + } + + $this->filesystem->mirror( + $source, + $this->appRoot . '/themes/contrib/emulsify/whisk', + ); + } + + /** + * Reads and decodes a YAML mapping. + * + * @return array + * Decoded mapping. + */ + private function readYaml(string $path): array { + $decoded = Yaml::decode($this->readFile($path)); + if (!is_array($decoded)) { + throw new \RuntimeException(sprintf('Expected "%s" to contain a YAML mapping.', $path)); + } + + return $decoded; + } + + /** + * Reads and decodes a JSON object. + * + * @return array + * Decoded object. + */ + private function readJson(string $path): array { + $decoded = json_decode($this->readFile($path), TRUE, flags: JSON_THROW_ON_ERROR); + if (!is_array($decoded)) { + throw new \RuntimeException(sprintf('Expected "%s" to contain a JSON object.', $path)); + } + + return $decoded; + } + + /** + * Reads a file or fails with a useful fixture error. + */ + private function readFile(string $path): string { + $contents = file_get_contents($path); + if ($contents === FALSE) { + throw new \RuntimeException(sprintf('Unable to read "%s".', $path)); + } + + return $contents; + } + + /** + * Reads sorted direct directory entries. + * + * @return list + * Direct entries excluding dot entries. + */ + private function readDirectoryEntries(string $path): array { + $entries = scandir($path); + if ($entries === FALSE) { + throw new \RuntimeException(sprintf('Unable to read directory "%s".', $path)); + } + + $entries = array_values(array_diff($entries, ['.', '..'])); + sort($entries); + return $entries; + } + +} diff --git a/tests/src/Integration/ThemeGeneration/EmulsifyThemeGeneratorTest.php b/tests/src/Integration/ThemeGeneration/EmulsifyThemeGeneratorTest.php new file mode 100644 index 0000000..dddbc68 --- /dev/null +++ b/tests/src/Integration/ThemeGeneration/EmulsifyThemeGeneratorTest.php @@ -0,0 +1,375 @@ +filesystem = new Filesystem(); + $this->appRoot = sys_get_temp_dir() . '/emulsify_tools_strategy_' . bin2hex(random_bytes(8)); + $this->filesystem->mkdir($this->appRoot); + } + + /** + * {@inheritdoc} + */ + protected function tearDown(): void { + if (isset($this->appRoot) && $this->filesystem->exists($this->appRoot)) { + $this->filesystem->remove($this->appRoot); + } + + parent::tearDown(); + } + + /** + * Tests valid Drupal Starterkit metadata selects the core generator. + */ + public function testValidModernSourceSelectsStarterkitGenerator(): void { + $this->writeWhiskSource( + $this->validInfoFile(), + "info: {}\n", + TRUE, + ); + $request = $this->createRequest(); + $expected = new ThemeGenerationResult(0, ['Generated by Drupal Starterkit.']); + + $starterkitGenerator = $this->createMock(ThemeGeneratorInterface::class); + $starterkitGenerator->expects($this->once()) + ->method('generate') + ->with($request) + ->willReturn($expected); + + $legacyFactoryCalls = 0; + $generator = $this->createGenerator( + $starterkitGenerator, + function () use (&$legacyFactoryCalls): ThemeGeneratorInterface { + $legacyFactoryCalls++; + return $this->createMock(ThemeGeneratorInterface::class); + }, + ); + + self::assertSame($expected, $generator->generate($request)); + self::assertSame(0, $legacyFactoryCalls); + } + + /** + * Tests a legacy-only Emulsify 6.x marker selects the legacy generator. + */ + public function testLegacyOnlySourceSelectsLegacyGenerator(): void { + $this->writeWhiskSource(NULL, NULL, TRUE); + $request = $this->createRequest(); + $expected = new ThemeGenerationResult(0, ['Generated by the legacy generator.']); + + $starterkitGenerator = $this->createMock(ThemeGeneratorInterface::class); + $starterkitGenerator->expects($this->never())->method('generate'); + + $legacyGenerator = $this->createMock(ThemeGeneratorInterface::class); + $legacyGenerator->expects($this->once()) + ->method('generate') + ->with($request) + ->willReturn($expected); + $legacyFactoryCalls = 0; + $generator = $this->createGenerator( + $starterkitGenerator, + static function () use (&$legacyFactoryCalls, $legacyGenerator): ThemeGeneratorInterface { + $legacyFactoryCalls++; + return $legacyGenerator; + }, + ); + + self::assertSame($expected, $generator->generate($request)); + self::assertSame(1, $legacyFactoryCalls); + } + + /** + * Tests an ordinary core failure never triggers the legacy fallback. + */ + public function testStarterkitFailureDoesNotFallBackToLegacyGenerator(): void { + $this->writeWhiskSource( + $this->validInfoFile(), + "info: {}\n", + TRUE, + ); + $request = $this->createRequest(); + $expected = new ThemeGenerationResult(1, ['Drupal Starterkit failed.']); + + $starterkitGenerator = $this->createMock(ThemeGeneratorInterface::class); + $starterkitGenerator->expects($this->once()) + ->method('generate') + ->with($request) + ->willReturn($expected); + $legacyFactoryCalls = 0; + $generator = $this->createGenerator( + $starterkitGenerator, + function () use (&$legacyFactoryCalls): ThemeGeneratorInterface { + $legacyFactoryCalls++; + return $this->createMock(ThemeGeneratorInterface::class); + }, + ); + + self::assertSame($expected, $generator->generate($request)); + self::assertSame(0, $legacyFactoryCalls); + } + + /** + * Tests a missing Emulsify base theme fails before either generator runs. + */ + public function testMissingBaseThemeReturnsActionableResult(): void { + $starterkitGenerator = $this->createMock(ThemeGeneratorInterface::class); + $starterkitGenerator->expects($this->never())->method('generate'); + $legacyFactoryCalls = 0; + $generator = $this->createGenerator( + $starterkitGenerator, + function () use (&$legacyFactoryCalls): ThemeGeneratorInterface { + $legacyFactoryCalls++; + return $this->createMock(ThemeGeneratorInterface::class); + }, + FALSE, + ); + + $this->assertPreflightFailure( + $generator->generate($this->createRequest()), + self::MISSING_BASE_THEME_MESSAGE, + ); + self::assertSame(0, $legacyFactoryCalls); + } + + /** + * Tests a missing Whisk directory fails before either generator runs. + */ + public function testMissingWhiskDirectoryReturnsActionableResult(): void { + $this->filesystem->mkdir($this->appRoot . '/themes/contrib/emulsify'); + $starterkitGenerator = $this->createMock(ThemeGeneratorInterface::class); + $starterkitGenerator->expects($this->never())->method('generate'); + $legacyFactoryCalls = 0; + $generator = $this->createGenerator( + $starterkitGenerator, + function () use (&$legacyFactoryCalls): ThemeGeneratorInterface { + $legacyFactoryCalls++; + return $this->createMock(ThemeGeneratorInterface::class); + }, + ); + + $this->assertPreflightFailure( + $generator->generate($this->createRequest()), + self::MISSING_STARTERKIT_MESSAGE, + ); + self::assertSame(0, $legacyFactoryCalls); + } + + /** + * Tests missing Starterkit metadata fails before Drupal core runs. + */ + public function testMissingStarterkitConfigReturnsActionableResult(): void { + $this->writeWhiskSource($this->validInfoFile(), NULL, TRUE); + $starterkitGenerator = $this->createMock(ThemeGeneratorInterface::class); + $starterkitGenerator->expects($this->never())->method('generate'); + $legacyFactoryCalls = 0; + $generator = $this->createGenerator( + $starterkitGenerator, + function () use (&$legacyFactoryCalls): ThemeGeneratorInterface { + $legacyFactoryCalls++; + return $this->createMock(ThemeGeneratorInterface::class); + }, + ); + + $this->assertPreflightFailure( + $generator->generate($this->createRequest()), + self::MISSING_STARTERKIT_CONFIG_MESSAGE, + ); + self::assertSame(0, $legacyFactoryCalls); + } + + /** + * Tests incomplete or malformed modern metadata never selects legacy. + */ + #[DataProvider('incompleteModernSourceProvider')] + public function testIncompleteModernSourceDoesNotSelectLegacy( + ?string $infoContents, + ?string $starterkitContents, + ): void { + $this->writeWhiskSource($infoContents, $starterkitContents, TRUE); + $request = $this->createRequest(); + $expected = new ThemeGenerationResult(1, ['Invalid Drupal Starterkit source.']); + + $starterkitGenerator = $this->createMock(ThemeGeneratorInterface::class); + $starterkitGenerator->expects($this->once()) + ->method('generate') + ->with($request) + ->willReturn($expected); + $legacyFactoryCalls = 0; + $generator = $this->createGenerator( + $starterkitGenerator, + function () use (&$legacyFactoryCalls): ThemeGeneratorInterface { + $legacyFactoryCalls++; + return $this->createMock(ThemeGeneratorInterface::class); + }, + ); + + self::assertSame($expected, $generator->generate($request)); + self::assertSame(0, $legacyFactoryCalls); + } + + /** + * Provides incomplete and malformed modern source combinations. + * + * @return array + * Source metadata keyed by scenario. + */ + public static function incompleteModernSourceProvider(): array { + $validInfo = << [NULL, "info: {}\n"], + 'malformed starterkit config' => [$validInfo, "info: [\n"], + 'malformed info file' => ["name: [\n", "info: {}\n"], + ]; + } + + /** + * Creates the strategy generator under test. + */ + private function createGenerator( + ThemeGeneratorInterface $starterkitGenerator, + \Closure $legacyFactory, + bool $baseThemeAvailable = TRUE, + ): EmulsifyThemeGenerator { + $themeExtensionList = $this->createMock(ThemeExtensionList::class); + if ($baseThemeAvailable) { + $themeExtensionList->method('getPath') + ->with('emulsify') + ->willReturn('themes/contrib/emulsify'); + } + else { + $themeExtensionList->method('getPath') + ->with('emulsify') + ->willThrowException(new UnknownExtensionException('The Emulsify theme does not exist.')); + } + + return new EmulsifyThemeGenerator( + $themeExtensionList, + $starterkitGenerator, + $legacyFactory, + $this->appRoot, + ); + } + + /** + * Asserts an actionable preflight failure. + */ + private function assertPreflightFailure( + ThemeGenerationResult $result, + string $expectedMessage, + ): void { + self::assertSame(1, $result->exitCode); + self::assertSame([$expectedMessage], $result->messages); + self::assertNull($result->destinationPath); + } + + /** + * Creates the generation request used by each strategy scenario. + */ + private function createRequest(): ThemeGenerationRequest { + return new ThemeGenerationRequest( + machineName: 'happy_theme', + displayName: 'Happy Theme', + description: 'Project theme.', + starterkitMachineName: 'whisk', + destinationPath: 'themes/custom', + ); + } + + /** + * Writes a Whisk source with the requested format markers. + */ + private function writeWhiskSource( + ?string $infoContents, + ?string $starterkitContents, + bool $includeLegacyMarker, + ): void { + $directory = $this->appRoot . '/themes/contrib/emulsify/whisk'; + $this->filesystem->mkdir($directory); + + if ($infoContents !== NULL) { + $this->filesystem->dumpFile($directory . '/whisk.info.yml', $infoContents); + } + if ($starterkitContents !== NULL) { + $this->filesystem->dumpFile($directory . '/whisk.starterkit.yml', $starterkitContents); + } + if ($includeLegacyMarker) { + $this->filesystem->dumpFile( + $directory . '/whisk.info.emulsify.yml', + "name: whisk\ntype: theme\nbase theme: emulsify\n", + ); + } + } + + /** + * Returns valid modern Whisk theme metadata. + */ + private function validInfoFile(): string { + return <<filesystem = new Filesystem(); + $this->appRoot = sys_get_temp_dir() . '/emulsify_tools_legacy_' . bin2hex(random_bytes(8)); + $sourceFixture = dirname(__DIR__, 3) . '/fixtures/ThemeGeneration/emulsify-6/whisk'; + if (!is_dir($sourceFixture)) { + throw new \RuntimeException(sprintf('Legacy Whisk fixture not found at "%s".', $sourceFixture)); + } + + $this->filesystem->mkdir($this->appRoot); + $this->filesystem->mirror( + $sourceFixture, + $this->appRoot . '/themes/contrib/emulsify/whisk', + ); + } + + /** + * {@inheritdoc} + */ + protected function tearDown(): void { + if (isset($this->appRoot) && $this->filesystem->exists($this->appRoot)) { + $this->filesystem->remove($this->appRoot); + } + + parent::tearDown(); + } + + /** + * Tests a real legacy fixture is generated with requested display metadata. + */ + public function testGenerateLegacyTheme(): void { + $request = new ThemeGenerationRequest( + machineName: 'legacy_theme', + displayName: "Legacy & Partners' Theme", + description: "Project theme: Callin's metadata & components #1.", + starterkitMachineName: 'whisk', + destinationPath: 'themes/custom', + ); + + $result = $this->createGenerator()->generate($request); + + self::assertSame(0, $result->exitCode); + self::assertSame([], $result->messages); + self::assertSame('themes/custom/legacy_theme', $result->destinationPath); + self::assertSame([self::DEPRECATION_WARNING], $result->warnings); + + $destination = $this->appRoot . '/themes/custom/legacy_theme'; + self::assertDirectoryExists($destination); + self::assertFileDoesNotExist($destination . '/whisk.info.emulsify.yml'); + self::assertFileDoesNotExist($destination . '/project.emulsify.json'); + + $info = $this->readYaml($destination . '/legacy_theme.info.yml'); + self::assertSame("Legacy & Partners' Theme", $info['name']); + self::assertSame("Project theme: Callin's metadata & components #1.", $info['description']); + self::assertSame([ + 'drupal:components (^3.0)', + 'drupal:emulsify_tools (^2.0)', + ], $info['dependencies']); + } + + /** + * Tests an existing destination is rejected without modifying its contents. + */ + public function testGenerateProtectsExistingDestination(): void { + $destination = $this->appRoot . '/themes/custom/protected_theme'; + $this->filesystem->mkdir($destination); + $this->filesystem->dumpFile($destination . '/sentinel.txt', "do not replace\n"); + $entriesBefore = $this->readDirectoryEntries($destination); + + $result = $this->createGenerator()->generate(new ThemeGenerationRequest( + machineName: 'protected_theme', + displayName: 'Protected Theme', + description: 'Must not be generated.', + starterkitMachineName: 'whisk', + destinationPath: 'themes/custom', + )); + + self::assertSame(1, $result->exitCode); + self::assertNull($result->destinationPath); + self::assertSame( + ['The destination theme already exists: themes/custom/protected_theme'], + $result->messages, + ); + self::assertSame([self::DEPRECATION_WARNING], $result->warnings); + self::assertSame($entriesBefore, $this->readDirectoryEntries($destination)); + self::assertSame("do not replace\n", $this->readFile($destination . '/sentinel.txt')); + } + + /** + * Creates the legacy generator under test. + */ + private function createGenerator(): LegacyThemeGenerator { + $themeExtensionList = $this->createMock(ThemeExtensionList::class); + $themeExtensionList->method('exists') + ->with('emulsify') + ->willReturn(TRUE); + $themeExtensionList->method('getPath') + ->with('emulsify') + ->willReturn('themes/contrib/emulsify'); + + return new LegacyThemeGenerator( + $themeExtensionList, + new StarterRecipeArchiveExtractor($this->filesystem), + new SubThemeGenerator($this->filesystem), + $this->filesystem, + $this->appRoot, + ); + } + + /** + * Reads and decodes a YAML mapping. + * + * @return array + * The decoded mapping. + */ + private function readYaml(string $path): array { + $decoded = Yaml::decode($this->readFile($path)); + if (!is_array($decoded)) { + throw new \RuntimeException(sprintf('Expected "%s" to contain a YAML mapping.', $path)); + } + + return $decoded; + } + + /** + * Reads a file or fails with a useful fixture error. + */ + private function readFile(string $path): string { + $contents = file_get_contents($path); + if ($contents === FALSE) { + throw new \RuntimeException(sprintf('Unable to read "%s".', $path)); + } + + return $contents; + } + + /** + * Reads the direct entries in a directory. + * + * @return list + * Sorted direct entries, excluding dot entries. + */ + private function readDirectoryEntries(string $path): array { + $entries = scandir($path); + if ($entries === FALSE) { + throw new \RuntimeException(sprintf('Unable to read directory "%s".', $path)); + } + + return array_values(array_diff($entries, ['.', '..'])); + } + +} diff --git a/tests/src/Integration/ThemeGenerationServicesTest.php b/tests/src/Integration/ThemeGenerationServicesTest.php new file mode 100644 index 0000000..e4d7613 --- /dev/null +++ b/tests/src/Integration/ThemeGenerationServicesTest.php @@ -0,0 +1,71 @@ +loadContainer(); + + self::assertSame( + EmulsifyThemeGenerator::class, + (string) $container->getAlias(ThemeGeneratorInterface::class), + ); + self::assertTrue($container->hasDefinition(EmulsifyThemeGenerator::class)); + self::assertTrue($container->hasDefinition(DrupalStarterkitThemeGenerator::class)); + self::assertTrue($container->hasDefinition(LegacyThemeGenerator::class)); + self::assertTrue($container->hasDefinition(ThemeMachineNameFactory::class)); + } + + /** + * Tests legacy service definitions use Drupal deprecation metadata. + */ + public function testLegacyServiceDeprecationMetadata(): void { + $container = $this->loadContainer(); + + foreach ([ + 'emulsify_tools.subtheme_generator', + LegacyThemeGenerator::class, + 'Drupal\\emulsify_tools\\Archive\\StarterRecipeArchiveExtractor', + ] as $serviceId) { + $definition = $container->getDefinition($serviceId); + self::assertTrue($definition->isDeprecated()); + $deprecation = $definition->getDeprecation($serviceId); + self::assertStringContainsString('deprecated in emulsify_tools:2.2.0', $deprecation['message']); + self::assertStringContainsString('removed from emulsify_tools:3.0.0', $deprecation['message']); + self::assertStringContainsString(ThemeGeneratorInterface::class, $deprecation['message']); + } + } + + /** + * Loads the module service definitions into a test container. + */ + private function loadContainer(): ContainerBuilder { + $container = new ContainerBuilder(); + (new YamlFileLoader($container))->load(dirname(__DIR__, 3) . '/emulsify_tools.services.yml'); + + return $container; + } + +} diff --git a/tests/src/Unit/ThemeNamespaceRegistryTest.php b/tests/src/Integration/ThemeNamespaceRegistryTest.php similarity index 98% rename from tests/src/Unit/ThemeNamespaceRegistryTest.php rename to tests/src/Integration/ThemeNamespaceRegistryTest.php index f5155a9..5183288 100644 --- a/tests/src/Unit/ThemeNamespaceRegistryTest.php +++ b/tests/src/Integration/ThemeNamespaceRegistryTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Drupal\Tests\emulsify_tools\Unit; +namespace Drupal\Tests\emulsify_tools\Integration; use Drupal\Core\Cache\Cache; use Drupal\Core\Cache\CacheBackendInterface; @@ -158,7 +158,7 @@ public static function providerDevelopmentTwigConfig(): array { * * @param \Drupal\Core\Extension\Extension $theme * Theme extension fixture. - * @param \Drupal\Tests\emulsify_tools\Unit\RecordingNamespaceCacheBackend $cacheBackend + * @param \Drupal\Tests\emulsify_tools\Integration\RecordingNamespaceCacheBackend $cacheBackend * Recording cache backend. * @param array $twigConfig * Twig configuration parameters. @@ -267,7 +267,7 @@ private function removeDirectory(string $path): void { } /** - * Recording cache backend for namespace registry unit tests. + * Recording cache backend for namespace registry integration tests. */ final class RecordingNamespaceCacheBackend implements CacheBackendInterface { diff --git a/tests/src/Unit/DrushServicesTest.php b/tests/src/Unit/DrushServicesTest.php deleted file mode 100644 index 2700e46..0000000 --- a/tests/src/Unit/DrushServicesTest.php +++ /dev/null @@ -1,49 +0,0 @@ -readFile(dirname(__DIR__, 3) . '/drush.services.yml')); - - self::assertIsArray($services); - self::assertSame(SubThemeCommands::class, $services['services']['emulsify_tools.commands']['class']); - self::assertSame([ - '@extension.list.theme', - '@Drupal\emulsify_tools\Archive\StarterRecipeArchiveExtractor', - '@emulsify_tools.subtheme_generator', - '@emulsify_tools.filesystem', - '@Drupal\emulsify_tools\Favicon\ChildThemeFaviconConfigRepairer', - ], $services['services']['emulsify_tools.commands']['arguments']); - } - - /** - * Reads a fixture file. - */ - private function readFile(string $path): string { - $contents = file_get_contents($path); - if ($contents === FALSE) { - throw new \RuntimeException(sprintf('Failed to read file "%s".', $path)); - } - - return $contents; - } - -} diff --git a/tests/src/Unit/SubThemeCommandsTest.php b/tests/src/Unit/SubThemeCommandsTest.php index 1c51435..a17314e 100644 --- a/tests/src/Unit/SubThemeCommandsTest.php +++ b/tests/src/Unit/SubThemeCommandsTest.php @@ -4,132 +4,200 @@ namespace Drupal\Tests\emulsify_tools\Unit; +use Drupal\Component\Transliteration\TransliterationInterface; use Drupal\Core\Extension\ThemeExtensionList; -use Drupal\emulsify_tools\Archive\StarterRecipeArchiveExtractor; use Drupal\emulsify_tools\Drush\Commands\SubThemeCommands; -use Drupal\emulsify_tools\Favicon\ChildThemeFaviconConfigRepairer; -use Drupal\emulsify_tools\SubThemeGenerator; +use Drupal\emulsify_tools\ThemeGeneration\ThemeGenerationRequest; +use Drupal\emulsify_tools\ThemeGeneration\ThemeGenerationResult; +use Drupal\emulsify_tools\ThemeGeneration\ThemeGeneratorInterface; +use Drupal\emulsify_tools\ThemeGeneration\ThemeMachineNameFactory; use Drupal\Tests\UnitTestCase; +use Drush\Attributes\Argument as ArgumentAttribute; +use Drush\Attributes\Command as CommandAttribute; +use Drush\Attributes\Help; +use Drush\Attributes\Option; use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; use Psr\Log\AbstractLogger; use Psr\Log\LogLevel; -use Symfony\Component\Filesystem\Filesystem; /** - * Tests child theme Drush command validation. + * Tests child-theme Drush command behavior. */ #[CoversClass(SubThemeCommands::class)] #[Group('emulsify_tools')] final class SubThemeCommandsTest extends UnitTestCase { /** - * Filesystem helper. + * Tests command aliases and distinct positional and display-name help. */ - private Filesystem $filesystem; + public function testCommandMetadata(): void { + $method = new \ReflectionMethod(SubThemeCommands::class, 'generateSubTheme'); + $command = $method->getAttributes(CommandAttribute::class)[0]->newInstance(); + self::assertSame('emulsify_tools:bake', $command->name); + self::assertSame(['emulsify', 'emulsify_tools:generate-theme'], $command->aliases); + + $help = $method->getAttributes(Help::class)[0]->newInstance(); + self::assertStringContainsString('positional value', (string) $help->synopsis); + self::assertStringContainsString('--name', (string) $help->synopsis); + + $argument = $method->getAttributes(ArgumentAttribute::class)[0]->newInstance(); + self::assertStringContainsString('machine name or label', $argument->description); + $nameOption = array_values(array_filter( + array_map( + static fn (\ReflectionAttribute $attribute): Option => $attribute->newInstance(), + $method->getAttributes(Option::class), + ), + static fn (Option $option): bool => $option->name === 'name', + ))[0]; + self::assertStringContainsString('Human-readable display name', $nameOption->description); + } /** - * Temporary fixture directory. + * Tests an explicit display-name option is passed to the generator. */ - private string $temporaryDirectory; + public function testGenerateSubThemeUsesExplicitDisplayName(): void { + $generator = $this->createSuccessfulGenerator(); + $logger = new SubThemeCommandRecordingLogger(); + $command = $this->createCommand(logger: $logger, themeGenerator: $generator); + + self::assertSame(0, $command->generateSubTheme('Happy Project', [ + 'name' => 'Happy Theme', + 'description' => 'Project theme.', + ])); + + $request = $generator->request(); + self::assertSame('happy_project', $request->machineName); + self::assertSame('Happy Theme', $request->displayName); + self::assertSame('Project theme.', $request->description); + self::assertSame('whisk', $request->starterkitMachineName); + self::assertSame('themes/custom', $request->destinationPath); + self::assertTrue($logger->hasRecordContaining( + LogLevel::NOTICE, + 'Using "happy_project"', + 'Happy Project', + )); + } /** - * {@inheritdoc} + * Tests the positional value is the default display name. */ - protected function setUp(): void { - parent::setUp(); + public function testGenerateSubThemeDefaultsDisplayNameToArgument(): void { + $generator = $this->createSuccessfulGenerator(); + $command = $this->createCommand(themeGenerator: $generator); - $this->filesystem = new Filesystem(); - $this->temporaryDirectory = sys_get_temp_dir() . '/emulsify_tools_command_' . bin2hex(random_bytes(8)); - $this->filesystem->mkdir($this->temporaryDirectory); + self::assertSame(0, $command->generateSubTheme('Happy Project')); + self::assertSame('Happy Project', $generator->request()->displayName); } /** - * {@inheritdoc} + * Tests explicit empty display names are forwarded without reinterpretation. */ - protected function tearDown(): void { - if (isset($this->temporaryDirectory) && $this->filesystem->exists($this->temporaryDirectory)) { - $this->filesystem->remove($this->temporaryDirectory); - } + #[DataProvider('emptyDisplayNameProvider')] + public function testGenerateSubThemePreservesEmptyDisplayName(string $displayName): void { + $generator = $this->createSuccessfulGenerator(); + $command = $this->createCommand(themeGenerator: $generator); - parent::tearDown(); + self::assertSame(0, $command->generateSubTheme('happy_theme', ['name' => $displayName])); + self::assertSame($displayName, $generator->request()->displayName); } /** - * Tests leading-digit names are rejected. + * Provides empty display-name options. + * + * @return array + * Display names keyed by scenario. */ - public function testGenerateSubThemeRejectsLeadingDigitMachineName(): void { - $command = $this->createCommand(); - - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('must start with a lowercase letter'); - $command->generateSubTheme('123 Theme'); + public static function emptyDisplayNameProvider(): array { + return [ + 'empty' => [''], + 'whitespace only' => [" \t "], + ]; } /** - * Tests names over Drupal's extension-name length limit are rejected. + * Tests descriptions are forwarded without shell or YAML interpretation. */ - public function testGenerateSubThemeRejectsTooLongMachineName(): void { - $command = $this->createCommand(); + #[DataProvider('descriptionProvider')] + public function testGenerateSubThemePreservesDescription(string $description): void { + $generator = $this->createSuccessfulGenerator(); + $command = $this->createCommand(themeGenerator: $generator); - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage(sprintf( - 'must be %d characters or fewer', - \DRUPAL_EXTENSION_NAME_MAX_LENGTH, - )); - $command->generateSubTheme(str_repeat('a', \DRUPAL_EXTENSION_NAME_MAX_LENGTH + 1)); + self::assertSame(0, $command->generateSubTheme('happy_theme', ['description' => $description])); + self::assertSame($description, $generator->request()->description); } /** - * Tests the Emulsify base theme machine name is reserved. + * Provides descriptions with significant punctuation and whitespace. + * + * @return array + * Descriptions keyed by scenario. */ - public function testGenerateSubThemeRejectsEmulsifyBaseThemeName(): void { - $command = $this->createCommand(); - - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('reserved by the Emulsify base theme'); - $command->generateSubTheme('emulsify'); + public static function descriptionProvider(): array { + return [ + 'quotes' => ['A "quoted" theme with an apostrophe\'s metadata.'], + 'ampersand' => ['Research & Development'], + 'colon' => ['Project theme: metadata'], + 'newlines' => ["First line\nSecond line"], + 'YAML-significant characters' => ["---\nkey: [one, two]\n# comment\n*alias\n!tag"], + ]; } /** - * Tests names that collide with existing themes are rejected. + * Tests nonzero generator results and failure-message logging. */ - public function testGenerateSubThemeRejectsExistingThemeName(): void { - $command = $this->createCommand(['stark']); + public function testGenerateSubThemeReturnsNonzeroExitCodeAndLogsErrors(): void { + $logger = new SubThemeCommandRecordingLogger(); + $generator = new SubThemeCommandFakeThemeGenerator(new ThemeGenerationResult( + 7, + ['First generation failure.', 'Second generation failure.'], + )); + $command = $this->createCommand(logger: $logger, themeGenerator: $generator); - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('already used by an existing Drupal theme'); - $command->generateSubTheme('Stark'); + self::assertSame(7, $command->generateSubTheme('happy_theme')); + self::assertTrue($logger->hasRecordContaining(LogLevel::ERROR, 'First generation failure.')); + self::assertTrue($logger->hasRecordContaining(LogLevel::ERROR, 'Second generation failure.')); } /** - * Tests a valid label still generates a child theme. + * Tests generator exceptions retain their existing propagation behavior. */ - public function testGenerateSubThemeAcceptsValidLabelAndLogsMachineName(): void { - $emulsifyPath = $this->temporaryDirectory . '/themes/contrib/emulsify'; - $this->writeStarterRecipe($emulsifyPath . '/whisk'); - $this->filesystem->mkdir($this->temporaryDirectory . '/themes/custom'); + public function testGenerateSubThemePropagatesGeneratorException(): void { + $exception = new \RuntimeException('Generator exploded.'); + $generator = new SubThemeCommandFakeThemeGenerator($exception); + $command = $this->createCommand(themeGenerator: $generator); - $logger = new SubThemeCommandRecordingLogger(); - $command = $this->createCommand(['emulsify', 'stark'], $emulsifyPath, $logger); - - $workingDirectory = getcwd(); - if ($workingDirectory === FALSE) { - throw new \RuntimeException('Unable to determine the current working directory.'); - } - - chdir($this->temporaryDirectory); try { - self::assertSame(0, $command->generateSubTheme('Happy Theme')); + $command->generateSubTheme('happy_theme'); + self::fail('Expected the generator exception to be propagated.'); } - finally { - chdir($workingDirectory); + catch (\RuntimeException $caught) { + self::assertSame($exception, $caught); } - $generatedInfoFile = $this->temporaryDirectory . '/themes/custom/happy_theme/happy_theme.info.yml'; - self::assertFileExists($generatedInfoFile); - self::assertSame("name: Happy Theme\n", $this->readFile($generatedInfoFile)); - self::assertTrue($logger->hasNoticeContaining('Using "happy_theme"', 'Happy Theme')); + self::assertSame('happy_theme', $generator->request()->machineName); + } + + /** + * Tests successful generator messages are logged as notices. + */ + public function testGenerateSubThemeLogsSuccessfulMessages(): void { + $logger = new SubThemeCommandRecordingLogger(); + $themeGenerator = new SubThemeCommandFakeThemeGenerator(new ThemeGenerationResult( + 0, + ['Theme generated successfully.'], + 'themes/custom/happy_theme', + ['The legacy Emulsify Drupal 6.x generation path is deprecated.'], + )); + + $command = $this->createCommand([], $logger, $themeGenerator); + self::assertSame(0, $command->generateSubTheme('happy_theme')); + self::assertTrue($logger->hasRecordContaining(LogLevel::NOTICE, 'Theme generated successfully.')); + self::assertTrue($logger->hasRecordContaining( + LogLevel::WARNING, + 'legacy Emulsify Drupal 6.x generation path is deprecated', + )); } /** @@ -137,27 +205,26 @@ public function testGenerateSubThemeAcceptsValidLabelAndLogsMachineName(): void * * @param string[] $existingThemes * Existing theme machine names. - * @param string|null $emulsifyPath - * Drupal-root-relative or absolute Emulsify theme path. * @param \Drupal\Tests\emulsify_tools\Unit\SubThemeCommandRecordingLogger|null $logger * Optional command logger. + * @param \Drupal\emulsify_tools\ThemeGeneration\ThemeGeneratorInterface|null $themeGenerator + * Optional theme generator. */ private function createCommand( array $existingThemes = [], - ?string $emulsifyPath = NULL, ?SubThemeCommandRecordingLogger $logger = NULL, + ?ThemeGeneratorInterface $themeGenerator = NULL, ): SubThemeCommands { $themeExtensionList = $this->createMock(ThemeExtensionList::class); - $themeExtensionList->method('getList')->willReturn(array_fill_keys($existingThemes, (object) [])); - $themeExtensionList->method('getPath') - ->willReturnCallback(static fn (string $themeName): string => $themeName === 'emulsify' ? (string) $emulsifyPath : ''); + $themeExtensionList->method('exists') + ->willReturnCallback(static fn (string $name): bool => in_array($name, $existingThemes, TRUE)); + $transliteration = $this->createMock(TransliterationInterface::class); + $transliteration->method('transliterate') + ->willReturnCallback(static fn (string $name): string => $name); $command = new SubThemeCommands( - $themeExtensionList, - new StarterRecipeArchiveExtractor($this->filesystem), - new SubThemeGenerator($this->filesystem), - $this->filesystem, - new ChildThemeFaviconConfigRepairer($this->temporaryDirectory, $themeExtensionList, $this->filesystem), + new ThemeMachineNameFactory($transliteration, $themeExtensionList), + $themeGenerator ?? $this->createSuccessfulGenerator(), ); if ($logger !== NULL) { $command->setLogger($logger); @@ -167,34 +234,48 @@ private function createCommand( } /** - * Writes a minimal Whisk starter recipe. + * Creates a successful capturing generator fake. */ - private function writeStarterRecipe(string $directory): void { - $this->filesystem->mkdir($directory); - $this->writeFile($directory . '/whisk.info.emulsify.yml', "hidden: false\n"); - $this->writeFile($directory . '/whisk.info.yml', "name: EMULSIFY_NAME\n"); + private function createSuccessfulGenerator(): SubThemeCommandFakeThemeGenerator { + return new SubThemeCommandFakeThemeGenerator(new ThemeGenerationResult(0, [])); } +} + +/** + * Captures command requests and returns a configured result or exception. + */ +final class SubThemeCommandFakeThemeGenerator implements ThemeGeneratorInterface { + /** - * Writes a test fixture file. + * Captured generation request. */ - private function writeFile(string $path, string $contents): void { - $result = file_put_contents($path, $contents); - if ($result === FALSE) { - throw new \RuntimeException(sprintf('Failed to write fixture file "%s".', $path)); - } - } + private ?ThemeGenerationRequest $request = NULL; + + /** + * Creates the fake generator. + */ + public function __construct( + private readonly ThemeGenerationResult|\Throwable $outcome, + ) {} /** - * Reads a generated file. + * {@inheritdoc} */ - private function readFile(string $path): string { - $contents = file_get_contents($path); - if ($contents === FALSE) { - throw new \RuntimeException(sprintf('Failed to read fixture file "%s".', $path)); + public function generate(ThemeGenerationRequest $request): ThemeGenerationResult { + $this->request = $request; + if ($this->outcome instanceof \Throwable) { + throw $this->outcome; } - return $contents; + return $this->outcome; + } + + /** + * Returns the captured request. + */ + public function request(): ThemeGenerationRequest { + return $this->request ?? throw new \LogicException('No generation request was captured.'); } } @@ -226,11 +307,11 @@ public function log($level, \Stringable|string $message, array $context = []): v } /** - * Returns whether a notice contains all provided fragments. + * Returns whether a record at the requested level contains all fragments. */ - public function hasNoticeContaining(string ...$fragments): bool { + public function hasRecordContaining(string $level, string ...$fragments): bool { foreach ($this->records as $record) { - if ($record['level'] !== LogLevel::NOTICE) { + if ($record['level'] !== $level) { continue; } foreach ($fragments as $fragment) { diff --git a/tests/src/Unit/SubThemeGeneratorTest.php b/tests/src/Unit/SubThemeGeneratorTest.php deleted file mode 100644 index 356cc8e..0000000 --- a/tests/src/Unit/SubThemeGeneratorTest.php +++ /dev/null @@ -1,143 +0,0 @@ -filesystem = new Filesystem(); - $this->temporaryDirectory = sys_get_temp_dir() . '/emulsify_tools_' . bin2hex(random_bytes(8)); - $this->filesystem->mkdir($this->temporaryDirectory); - } - - /** - * {@inheritdoc} - */ - protected function tearDown(): void { - if (isset($this->temporaryDirectory) && $this->filesystem->exists($this->temporaryDirectory)) { - $this->filesystem->remove($this->temporaryDirectory); - } - - parent::tearDown(); - } - - /** - * Tests file, directory, and content replacements during generation. - */ - public function testGenerateRenamesThemeAssetsAndRewritesContent(): void { - $themeDirectory = $this->temporaryDirectory . '/theme'; - $this->filesystem->mkdir([ - $themeDirectory, - $themeDirectory . '/components/whisk-parent/whisk-child', - ]); - - $this->writeFile( - $themeDirectory . '/whisk.info.yml', - "name: EMULSIFY_NAME\n", - ); - $this->writeFile( - $themeDirectory . '/whisk.info.emulsify.yml', - "hidden: false\n", - ); - $this->writeFile( - $themeDirectory . '/whisk.starterkit.yml', - "ignore: []\n", - ); - $this->writeFile( - $themeDirectory . '/project.emulsify.json', - "{}\n", - ); - $this->writeFile( - $themeDirectory . '/components/whisk-parent/whisk-child/whisk-template.twig', - "machine: whisk\nlabel: EMULSIFY_NAME\n", - ); - $this->writeFile( - $themeDirectory . '/README.md', - "Theme: EMULSIFY_NAME (whisk)\n", - ); - - $generator = new SubThemeGenerator($this->filesystem); - $generator->generate($themeDirectory, 'new_theme', 'New Theme'); - - self::assertFileDoesNotExist($themeDirectory . '/whisk.info.emulsify.yml'); - self::assertFileDoesNotExist($themeDirectory . '/whisk.starterkit.yml'); - self::assertFileDoesNotExist($themeDirectory . '/project.emulsify.json'); - self::assertFileExists($themeDirectory . '/new_theme.info.yml'); - self::assertSame("name: New Theme\n", $this->readFile($themeDirectory . '/new_theme.info.yml')); - - self::assertDirectoryDoesNotExist($themeDirectory . '/components/whisk-parent'); - self::assertDirectoryExists($themeDirectory . '/components/new_theme-parent/new_theme-child'); - self::assertFileExists($themeDirectory . '/components/new_theme-parent/new_theme-child/new_theme-template.twig'); - self::assertSame( - "machine: new_theme\nlabel: New Theme\n", - $this->readFile($themeDirectory . '/components/new_theme-parent/new_theme-child/new_theme-template.twig'), - ); - - self::assertSame("Theme: New Theme (new_theme)\n", $this->readFile($themeDirectory . '/README.md')); - } - - /** - * Tests that the source theme info file is required. - */ - public function testGenerateRequiresAnEmulsifyInfoFile(): void { - $themeDirectory = $this->temporaryDirectory . '/missing-info'; - $this->filesystem->mkdir($themeDirectory); - - $generator = new SubThemeGenerator($this->filesystem); - - $this->expectException(\RuntimeException::class); - $this->expectExceptionMessage(sprintf('No *.info.emulsify.yml file was found in "%s".', $themeDirectory)); - $generator->generate($themeDirectory, 'new_theme', 'New Theme'); - } - - /** - * Writes a test fixture file. - */ - private function writeFile(string $path, string $contents): void { - $result = file_put_contents($path, $contents); - if ($result === FALSE) { - throw new \RuntimeException(sprintf('Failed to write fixture file "%s".', $path)); - } - } - - /** - * Reads a generated file. - */ - private function readFile(string $path): string { - $contents = file_get_contents($path); - if ($contents === FALSE) { - throw new \RuntimeException(sprintf('Failed to read fixture file "%s".', $path)); - } - - return $contents; - } - -} diff --git a/tests/src/Unit/ThemeGeneration/ThemeMachineNameFactoryTest.php b/tests/src/Unit/ThemeGeneration/ThemeMachineNameFactoryTest.php new file mode 100644 index 0000000..85a2afb --- /dev/null +++ b/tests/src/Unit/ThemeGeneration/ThemeMachineNameFactoryTest.php @@ -0,0 +1,171 @@ +createFactory($input, $transliterated)->create($input); + + self::assertSame($input, $resolved->originalInput); + self::assertSame($expected, $resolved->machineName); + self::assertSame($wasNormalized, $resolved->wasNormalized()); + } + + /** + * Provides representative normalization scenarios. + * + * @return array + * Input, transliteration result, machine name, and normalization flag. + */ + public static function normalizationProvider(): array { + return [ + 'ordinary ASCII machine name' => ['my_theme', 'my_theme', 'my_theme', FALSE], + 'accented Latin text' => [' Crème Brûlée ', 'Creme Brulee', 'creme_brulee', TRUE], + 'multiple spaces and punctuation' => ['My Theme: Project!', 'My Theme: Project!', 'my_theme_project', TRUE], + 'repeated separators' => ['__My___Theme--Name__', '__My___Theme--Name__', 'my_theme_name', TRUE], + 'usable non-Latin transliteration' => ['東京', 'dongjing', 'dongjing', TRUE], + ]; + } + + /** + * Tests a name at Drupal's maximum extension-name length. + */ + public function testAcceptsMaximumLengthBoundary(): void { + $input = str_repeat('a', \DRUPAL_EXTENSION_NAME_MAX_LENGTH); + + self::assertSame($input, $this->createFactory($input, $input)->create($input)->machineName); + } + + /** + * Tests leading digits produce an actionable diagnostic. + */ + public function testRejectsLeadingDigit(): void { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('must start with a lowercase letter. Start the theme name with a letter, for example "my_theme".'); + + $this->createFactory('123 Theme', '123 Theme')->create('123 Theme'); + } + + /** + * Tests empty and whitespace-only input. + */ + #[DataProvider('emptyInputProvider')] + public function testRejectsEmptyInput(string $input): void { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('could not be converted to a valid Drupal machine name. Enter a name containing letters or numbers, for example "My Project Theme".'); + + $this->createFactory($input, '')->create($input); + } + + /** + * Provides input without meaningful characters. + * + * @return array + * Empty input cases. + */ + public static function emptyInputProvider(): array { + return [ + 'empty' => [''], + 'whitespace only' => [" \t\n "], + ]; + } + + /** + * Tests non-Latin input without a usable transliteration. + */ + public function testRejectsUnusableNonLatinTransliteration(): void { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('could not be converted to a valid Drupal machine name'); + + $this->createFactory('😀', '_')->create('😀'); + } + + /** + * Tests names over Drupal's extension-name length limit. + */ + public function testRejectsNameOverMaximumLength(): void { + $input = str_repeat('a', \DRUPAL_EXTENSION_NAME_MAX_LENGTH + 1); + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage(sprintf( + 'must be %d characters or fewer. Choose a shorter name, for example "my_project_theme".', + \DRUPAL_EXTENSION_NAME_MAX_LENGTH, + )); + + $this->createFactory($input, $input)->create($input); + } + + /** + * Tests the Emulsify base theme name is reserved. + */ + public function testRejectsReservedEmulsifyName(): void { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('reserved by the Emulsify base theme. Choose a unique child theme name, for example "my_project_theme".'); + + $this->createFactory('Emulsify', 'Emulsify')->create('Emulsify'); + } + + /** + * Tests collisions with discovered themes. + */ + public function testRejectsExistingThemeCollision(): void { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('already used by an existing Drupal theme. Choose a unique child theme name, for example "my_project_theme".'); + + $this->createFactory('Stark', 'Stark', ['stark'])->create('Stark'); + } + + /** + * Creates a factory with deterministic collaborators. + * + * @param string $input + * Original input passed to the transliteration service. + * @param string $transliterated + * Deterministic transliteration result. + * @param string[] $existingThemes + * Existing theme machine names. + */ + private function createFactory( + string $input, + string $transliterated, + array $existingThemes = [], + ): ThemeMachineNameFactory { + $transliteration = $this->createMock(TransliterationInterface::class); + $transliteration->expects($this->once()) + ->method('transliterate') + ->with(trim($input), LanguageInterface::LANGCODE_DEFAULT, '_') + ->willReturn($transliterated); + + $themeExtensionList = $this->createMock(ThemeExtensionList::class); + $themeExtensionList->method('exists') + ->willReturnCallback(static fn (string $name): bool => in_array($name, $existingThemes, TRUE)); + + return new ThemeMachineNameFactory($transliteration, $themeExtensionList); + } + +}