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);
+ }
+
+}