diff --git a/.agents/skills/dq-add-recipe/SKILL.md b/.agents/skills/dq-add-recipe/SKILL.md index 2727ac6..2985b9b 100644 --- a/.agents/skills/dq-add-recipe/SKILL.md +++ b/.agents/skills/dq-add-recipe/SKILL.md @@ -98,19 +98,56 @@ variables your submodule sets. `dq:scaffold` replaces the literal token `STARTERKIT` with the theme machine name in contents/filenames — only needed if a template must name the theme (rare). +**Type/spacing MUST use the preset contract utilities** — `text-title`, +`text-meta`, `gap-flow`, `py-flow`, `py-row` (plus the color utilities +`text-ink`/`text-muted`/`bg-rule`/…) — never raw `text-sm`/`gap-6`. That is how +every preset's scale and negative space reach your markup. See +`docs/presets.md` § "The content-scale contract". + +## User-tunable options (recipe inputs) + +Declare options as native recipe inputs in `recipe.yml` — typed, described, +**always defaulted** (the recipe must work untouched): + +```yaml +input: + items_per_page: + data_type: integer + description: 'How many articles the writing view lists per page.' + default: { source: value, value: 30 } +``` + +Consume them in config actions as `'${items_per_page}'` (substitution is a +string replace — values arrive as strings). Users set them in config.dq.yml +(`- name: blog` / `options: {…}`); `dq:scaffold` passes them as +`--input=.=`. Inputs can only *parameterize* action +values, not skip actions — conditional behaviour belongs in the scaffold. +Avoid boolean inputs (CLI 'false' is truthy in PHP); prefer enumerated strings. + ## Catalog metadata (self-describing) The recipe declares its own catalog entry in **`composer.json`** — the package is -the single source of truth for its key and label: +the single source of truth for its key, label, and **placeable blocks** (offered +for config.dq.yml `homepage.blocks` composition): ```json { "name": "drupal-quick/recipe-blog", "type": "drupal-recipe", - "extra": { "dq": { "recipe": { "key": "blog", "label": "Blog — …" } } } + "extra": { "dq": { "recipe": { + "key": "blog", + "label": "Blog — …", + "blocks": { "recent": { "plugin": "views_block:writing-block_1", "label": "Recent writing" } } + } } } } ``` +Users reference blocks as `"/"` (e.g. `blog/recent`); the plugin +id for a views block is `views_block:-`. The registry +builder also summarises the recipe.yml `input:` block into the registry +(`options`), so the dq-init wizard can surface options before the package is +fetched — recipe.yml stays the single source of truth. + Do **not** hand-edit `templates/recipe-registry.json` — it is a generated cache. Run `php bin/dq-registry-build` (or let Quick CI run it) to enumerate the recipe packages, read each `extra.dq.recipe`, and regenerate the registry @@ -126,6 +163,11 @@ by referencing it inline in `config.dq.yml` (`{ package, url }`). `#[Hook]` methods register under the base hook with a bundle/view-id guard. - [ ] `recipe.yml` `install:` lists the submodule so it gets enabled. - [ ] Templates are in `theme-assets/`; any theme-name token uses `STARTERKIT`. -- [ ] `composer.json` declares `extra.dq.recipe` (`key` + `label`); regenerate - the cache with `php bin/dq-registry-build` (never hand-edit it). +- [ ] Templates use only preset-contract utilities for type scale and spacing + (`text-title`, `text-meta`, `gap-flow`, `py-row`) — no raw `text-sm`/`gap-6`. +- [ ] Any user-tunable option is a `recipe.yml` input with a default; actions + consume it as `'${name}'`. +- [ ] `composer.json` declares `extra.dq.recipe` (`key` + `label` + any + `blocks`); regenerate the cache with `php bin/dq-registry-build` (never + hand-edit it). - [ ] Applying it on a fresh scaffold creates the fields and renders correctly. diff --git a/.agents/skills/dq-conventions/SKILL.md b/.agents/skills/dq-conventions/SKILL.md index 673c7d2..a6d23e1 100644 --- a/.agents/skills/dq-conventions/SKILL.md +++ b/.agents/skills/dq-conventions/SKILL.md @@ -94,6 +94,41 @@ no dispatcher. - Self-hosted fonts are pulled **on demand**: a directory preset's `fonts.json` pins a URL + `sha256`, fetched into `src/fonts/` (gitignored) at preset time — **never commit font binaries**. See `docs/presets.md`. +- **Content-scale contract:** presets define `--text-title`, `--text-meta`, + `--spacing-flow` (+ `--spacing-row`); recipe templates use ONLY those + utilities (`text-title`, `text-meta`, `gap-flow`, `py-row`) for type scale and + inter-unit spacing — never raw `text-sm`/`gap-6`. That's how a preset's scale + reaches recipe markup. See `docs/presets.md` § contract. + +## Site composition (config.dq.yml → scaffold) + +- **Recipe options = native recipe inputs.** Declared in the recipe's + `recipe.yml` `input:` (typed, always defaulted), consumed in actions as + `'${name}'`, set by users via `- name: ` / `options:` entries, passed by + `dq:scaffold` as `--input=.=`. Inputs parameterize + action *values* only — they cannot skip actions; conditionality lives in the + scaffold. Substituted values arrive as strings. **`dq-install` writes each + fetched recipe's options into config.dq.yml as a commented block under its + entry** (in place; promotes a bare `- "key"` to `- name: "key"`; idempotent; + prefixes `# ` so uncommenting is valid YAML). `--exclude-options` prints to + the terminal instead. +- **Layout is baked at scaffold time, not a runtime setting.** The starterkit + ships the default shell (`templates/includes/page-shell.html.twig`, the + sidebar arrangement, embedded by both page templates) plus one file per + alternative (`page-shell--.html.twig`). `dq:scaffold` copies the + chosen variant over the shell and deletes the rest — afterwards the shell is + ordinary Twig the user edits directly. No theme setting, no settings form, + no runtime branch; only the chosen arrangement's classes get compiled. +- **Homepage composition:** recipes advertise placeable blocks in + `composer.json` `extra.dq.recipe.blocks`; `homepage.blocks` entries + (`"/"`, order = weight) become block config in the content + region, ``-only, and the front page moves to the dedicated always-empty + `/home` view. Capabilities ship with the recipe; *placement* is scaffold-side + selection. Omit `homepage:` → the recipes' own front page stands. +- The registry is a **generated cache** (`bin/dq-registry-build`) carrying + label/package/url + `blocks` + an `options` summary of each recipe's inputs + (so `dq-init --interactive` can ask before packages are fetched). Never + hand-edit it. ## Light-footprint rules @@ -119,5 +154,11 @@ This repo is **orchestrator-only**; the theme and recipes are separate packages. `CleanupCommand`, `StaticExportCommand`, `DeployCommand`; `DrupalQuickHelpers` trait). Standalone CLI scripts in `bin/` (`dq-init`, `dq-install`, `dq-registry-build`). +- **Pure logic lives in plain classes, not in the bins.** `src/Config/` + (`RecipeEntry`, `RecipeOptions`, `PresetDiscovery`) and `src/Registry/` + (`RegistryEntry`) hold the fiddly deterministic pieces; the bin scripts + `require_once` them directly (no autoloader assumption) and stay thin. + Unit-test any new logic of this kind in `tests/Unit/` — run with + `composer test` (no Drupal needed; CI runs it on every push). - Config/registry: `templates/config.dq.yml`, `templates/recipe-registry.json`. - Design notes: `docs/extensibility.md`, `docs/static-deploy.md`, `docs/structured-data.md`. diff --git a/.github/workflows/recipe-registry.yml b/.github/workflows/recipe-registry.yml index b469312..bd0fba7 100644 --- a/.github/workflows/recipe-registry.yml +++ b/.github/workflows/recipe-registry.yml @@ -24,6 +24,9 @@ jobs: uses: shivammathur/setup-php@v2 with: php-version: '8.3' + # The yaml extension lets the script summarise each recipe's input + # block (wizard-facing options) into the registry. + extensions: yaml # No Composer install needed — the script is standalone PHP with no deps. # GITHUB_TOKEN raises the API rate limit and reads public org repos. For diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..d245fbf --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,25 @@ +# Unit tests for the pure logic in src/ (config normalization, option +# injection, preset discovery, registry entry building). No Drupal site is +# involved — see phpunit.xml / tests/Unit. +name: Tests + +on: + push: + pull_request: + +jobs: + phpunit: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + + - name: Install dependencies + run: composer install --no-interaction --no-progress + + - name: Run the unit suite + run: composer test diff --git a/.gitignore b/.gitignore index ad76349..05c3d23 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,9 @@ .DS_Store # Claude Code local settings (machine-specific) -.claude/settings.local.json \ No newline at end of file +.claude/settings.local.json + +# Dev dependencies (unit tests) — resolved fresh; not shipped +/vendor/ +composer.lock +.phpunit.cache/ diff --git a/README.md b/README.md index 5b00dd8..b1fd200 100644 --- a/README.md +++ b/README.md @@ -67,11 +67,18 @@ theme: machine_name: "my_theme" title: "My Theme" preset: "minimal" # a design preset from the starterkit (presets/) + layout: "sidebar" # page shell baked at scaffold: sidebar | single build: true # false to skip the npm build recipes: - "core/recipes/standard" - - "blog" # short key from the recipe registry + - name: "blog" # short key from the recipe registry + options: # optional — the recipe's own inputs (defaults apply) + items_per_page: 10 + +# homepage: # optional — compose the front page from recipe blocks +# blocks: +# - "blog/recent" parameters: theme_design: @@ -92,8 +99,9 @@ static: # optional, used by `drush dq:static` ## What you get - A custom theme at `web/themes/custom/{machine_name}/`, generated from `dq_starterkit` and built with Vite + Tailwind CSS v4. -- Your chosen design **preset** (colors, type, optional fonts) plus `theme_design` overrides applied to the theme — swap it anytime with `npm run preset `. -- Recipe templates merged into the theme and recipe behaviour assembled as native OOP-hook submodules; all recipes applied in order against a minimal install. +- Your chosen design **preset** (colors, type, optional fonts) plus `theme_design` overrides applied to the theme — swap it anytime with `npm run preset ` — and your chosen page **layout** (`sidebar` | `single`), baked into the theme's shell template (`templates/includes/page-shell.html.twig`), which is yours to edit from then on. +- Recipe templates merged into the theme and recipe behaviour assembled as native OOP-hook submodules; all recipes applied in order against a minimal install, each with its `options:` passed as native recipe inputs. +- Optionally a **composed homepage**: recipe-advertised blocks placed in your order on a dedicated `/home` front page — ordinary block config you can rearrange later. - Module-free [Schema.org JSON-LD](docs/structured-data.md) on content pages (`BlogPosting` for articles, `WebPage` for pages), built from each node's own fields. --- @@ -110,6 +118,17 @@ PHP 8.1+ · Drupal 11.1.8+ (recipe modules use module preprocess OOP hooks) · D --- +## Tests + +Unit tests cover the pure logic in `src/` (config normalization, option injection, preset discovery, registry building) — no Drupal site required: + +```bash +composer install +composer test +``` + +--- + ## Roadmap - **Build out the recipe library** beyond the blog proof of concept. diff --git a/bin/dq-init b/bin/dq-init index bfbd6c8..b909356 100644 --- a/bin/dq-init +++ b/bin/dq-init @@ -33,6 +33,12 @@ $destination = $projectRoot . '/config.dq.yml'; $interactive = in_array('--interactive', $argv, true); $ddev = in_array('--ddev', $argv, true); +// Preset discovery is a plain class in this package — required directly, so +// the script keeps working without any Composer autoloader. +require_once $packageRoot . '/src/Config/PresetDiscovery.php'; + +use DrupalQuick\Config\PresetDiscovery; + // ------------------------------------------------------------------ Helpers function ask(string $question, string $default = ''): string { @@ -121,24 +127,17 @@ $themeTitle = ask('Theme display name', 'My Theme'); // Discover presets from the installed starterkit's package.json "dq" metadata // (presets + defaultPreset) — the single source of truth, which travels intact // through generate-theme. The starterkit ships as drupal-quick/dq_starterkit, -// installed at web/themes/contrib/dq_starterkit; a hardcoded set is the last -// resort if that manifest can't be read. -$starterkitDir = $projectRoot . '/web/themes/contrib/dq_starterkit'; -$presets = []; -$defaultPreset = 'minimal'; -if (file_exists($starterkitDir . '/package.json')) { - $meta = json_decode(file_get_contents($starterkitDir . '/package.json'), true) ?: []; - $presets = $meta['dq']['presets'] ?? []; - $defaultPreset = $meta['dq']['defaultPreset'] ?? $defaultPreset; -} -if (empty($presets)) { - $presets = ['minimal', 'corporate']; -} -if (!in_array($defaultPreset, $presets, true)) { - $defaultPreset = $presets[0]; -} +// installed at web/themes/contrib/dq_starterkit. +[$presets, $defaultPreset] = PresetDiscovery::discover($projectRoot . '/web/themes/contrib/dq_starterkit'); $themePreset = choose('Design preset', $presets, $defaultPreset); +// Page-shell layout — baked into the generated theme by dq:scaffold; the +// shell (templates/includes/page-shell.html.twig) is the user's to edit after. +echo "\nPage layout:\n"; +echo " sidebar — site title above a vertical menu in a left column\n"; +echo " single — one column, title + horizontal menu along the top\n"; +$themeLayout = choose('Page layout', ['sidebar', 'single'], 'sidebar'); + // Recipes // core/recipes/standard is always seeded as the base recipe — it installs the // standard content types and config that other recipes depend on. Additional @@ -150,7 +149,8 @@ $registry = file_exists($registryFile) ? json_decode(file_get_contents($registryFile), true) : []; -$recipeLines = [' - "core/recipes/standard"']; +$recipeLines = [' - "core/recipes/standard"']; +$chosenBlocks = []; if (!empty($registry)) { $options = []; @@ -158,13 +158,67 @@ if (!empty($registry)) { $options[] = ['key' => $key, 'label' => $info['label'] ?? $key]; } $chosen = chooseMany('Which drupalquick recipes would you like to include?', $options); + foreach ($chosen as $key) { - $recipeLines[] = " - \"{$key}\""; + $info = $registry[$key] ?? []; + + // Per-recipe options — summarised into the registry from each recipe's + // own recipe.yml input block. Enter keeps the default; the options form + // is only written when a value actually differs, so the generated file + // stays as small as the choices made. + $optLines = []; + foreach (($info['options'] ?? []) as $name => $def) { + $desc = $def['description'] !== '' ? $def['description'] : $name; + $default = $def['default'] ?? null; + $defaultStr = is_scalar($default) ? (string) $default : ''; + $answer = ask(" {$key} · {$name} — {$desc}", $defaultStr); + if ($answer !== '' && $answer !== $defaultStr) { + $bare = in_array($def['type'] ?? '', ['integer', 'float', 'boolean'], true) || is_numeric($answer); + $optLines[] = ' ' . $name . ': ' . ($bare ? $answer : "\"{$answer}\""); + } + } + + if ($optLines) { + $recipeLines[] = " - name: \"{$key}\""; + $recipeLines[] = ' options:'; + array_push($recipeLines, ...$optLines); + } else { + $recipeLines[] = " - \"{$key}\""; + } + + // Collect the blocks this recipe advertises for homepage composition. + foreach (($info['blocks'] ?? []) as $blockKey => $blockInfo) { + $ref = "{$key}/{$blockKey}"; + $chosenBlocks[$ref] = ($blockInfo['label'] ?? $ref) . " ({$ref})"; + } } } $recipesBlock = implode("\n", $recipeLines); +// Homepage composition — offer the chosen recipes' advertised blocks. Skipping +// keeps whatever front page the recipes set (e.g. the blog's /writing); picking +// blocks makes dq:scaffold compose the front page from them, in this order, at +// the dedicated /home view. +$homepageBlock = ''; +if (!empty($chosenBlocks)) { + $blockOptions = []; + foreach ($chosenBlocks as $ref => $label) { + $blockOptions[] = ['key' => $ref, 'label' => $label]; + } + $picked = chooseMany( + 'Compose the homepage from recipe blocks? Pick in display order (Enter keeps the recipes\' own front page)', + $blockOptions + ); + if (!empty($picked)) { + $lines = ['homepage:', ' blocks:']; + foreach ($picked as $ref) { + $lines[] = " - \"{$ref}\""; + } + $homepageBlock = "\n" . implode("\n", $lines) . "\n"; + } +} + // Only write admin_pass when the user supplied one — otherwise leave it out so // dq:scaffold generates a strong password and shows it once, keeping a secret // off disk by default. @@ -180,10 +234,11 @@ theme: machine_name: "{$themeMachine}" title: "{$themeTitle}" preset: "{$themePreset}" + layout: "{$themeLayout}" recipes: {$recipesBlock} - +{$homepageBlock} parameters: theme_design: primary_color: "#10b981" diff --git a/bin/dq-install b/bin/dq-install index a8dd446..c2b255b 100644 --- a/bin/dq-install +++ b/bin/dq-install @@ -22,10 +22,16 @@ * dependency. Composer only activates plugins declared at the root level, so * this package cannot declare it on the project's behalf — it must live in * the consumer's own composer.json. + * + * After installing, each recipe's user-tunable options (recipe.yml `input:`) + * are written back into config.dq.yml as a COMMENTED block under that recipe's + * entry, ready to uncomment. Pass --exclude-options to only list them in the + * terminal and leave config.dq.yml untouched. */ -$packageRoot = dirname(__DIR__); -$projectRoot = getcwd(); +$packageRoot = dirname(__DIR__); +$projectRoot = getcwd(); +$excludeOptions = in_array('--exclude-options', $argv, true); // Load the project autoloader so Symfony YAML is available. $autoload = $projectRoot . '/vendor/autoload.php'; @@ -34,6 +40,11 @@ if (!file_exists($autoload)) { } require_once $autoload; +// The injection logic is a plain class in this package — required directly so +// it resolves regardless of the consumer autoloader's state. +require_once $packageRoot . '/src/Config/RecipeOptions.php'; + +use DrupalQuick\Config\RecipeOptions; use Symfony\Component\Yaml\Yaml; use Symfony\Component\Yaml\Exception\ParseException; use Symfony\Component\Process\Process; @@ -53,6 +64,10 @@ function run_command(array $command, string $cwd): int { return $process->getExitCode() ?? 1; } +// The option-line rendering and commented-block injection live in +// DrupalQuick\Config\RecipeOptions (required above) so they are unit-testable; +// see tests/Unit/Config/RecipeOptionsTest.php. + // ------------------------------------------------------------------ Config $configFile = $projectRoot . '/config.dq.yml'; @@ -123,8 +138,13 @@ if (!in_array($peerPackage, $rootRequire, true)) { $toRequire = []; foreach ($recipes as $recipe) { - // A recipe entry is a registry key, an inline package spec - // ({package, url}), or a core/contrib path string (no Composer action). + // A recipe entry is a registry key, {name, options} (options map to the + // recipe's native inputs — consumed by dq:scaffold, not here), an inline + // package spec ({package, url, options?}), or a core/contrib path string + // (no Composer action). + if (is_array($recipe) && isset($recipe['name'])) { + $recipe = $recipe['name']; + } if (is_array($recipe)) { $package = $recipe['package'] ?? null; $url = $recipe['url'] ?? ''; @@ -190,4 +210,72 @@ foreach ($toRequire as $package) { } echo "\n✅ Recipe packages installed.\n"; -echo " Run `drush dq:scaffold` to build your site.\n"; + +// --------------------------------------------------------------- Options +// Each managed recipe may declare user-tunable options as native recipe inputs +// (recipe.yml `input:`). Now that the packages are unpacked we can read them. +// +// default → write them, commented, into the matching recipe entry +// in config.dq.yml. The user uncomments to enable; every +// input has a default, so an untouched file still works. +// --exclude-options → print them to the terminal instead, touching no file. +// +// Collect [config key => input definitions] for registry recipes with inputs. +$recipeOptions = []; +foreach ($recipes as $recipe) { + if (is_array($recipe) && isset($recipe['name'])) { + $key = $recipe['name']; + } elseif (is_string($recipe)) { + $key = $recipe; + } else { + continue; // inline package spec — no stable key to locate in the file + } + if (!isset($registry[$key]['package'])) { + continue; // core/contrib path, or a key with no registry entry + } + $pkg = $registry[$key]['package']; + $short = ($pos = strpos($pkg, '/')) !== false ? substr($pkg, $pos + 1) : $pkg; + $recipeFile = $projectRoot . '/recipes/' . $short . '/recipe.yml'; + if (!file_exists($recipeFile)) { + continue; + } + try { + $recipeData = Yaml::parseFile($recipeFile); + } catch (ParseException) { + continue; + } + if (!empty($recipeData['input'])) { + $recipeOptions[$key] = $recipeData['input']; + } +} + +if (!empty($recipeOptions) && $excludeOptions) { + echo "\nℹ️ Optional recipe settings — set under `options:` on the recipe's entry\n"; + echo " in config.dq.yml (defaults apply when unset):\n"; + foreach ($recipeOptions as $key => $inputs) { + echo " {$key}:\n"; + foreach ($inputs as $name => $def) { + $description = $def['description'] ?? ''; + $default = $def['default']['value'] ?? null; + $defaultText = is_scalar($default) ? ' [default: ' . var_export($default, true) . ']' : ''; + echo " - {$name}: {$description}{$defaultText}\n"; + } + } +} elseif (!empty($recipeOptions)) { + $lines = explode("\n", file_get_contents($configFile)); + $injected = []; + foreach ($recipeOptions as $key => $inputs) { + $before = $lines; + $lines = RecipeOptions::injectCommented($lines, $key, RecipeOptions::activeLines($inputs)); + if ($lines !== $before) { + $injected[] = $key; + } + } + if ($injected) { + file_put_contents($configFile, implode("\n", $lines)); + echo "\nℹ️ Wrote commented recipe options into config.dq.yml for: " . implode(', ', $injected) . ".\n"; + echo " Uncomment (remove the leading `# `) under each recipe to override its defaults.\n"; + } +} + +echo "\n Run `drush dq:scaffold` to build your site.\n"; diff --git a/bin/dq-registry-build b/bin/dq-registry-build index b56512b..ce83e67 100755 --- a/bin/dq-registry-build +++ b/bin/dq-registry-build @@ -35,6 +35,12 @@ $packageRoot = dirname(__DIR__); +// Entry building is a plain class in this package — required directly, so the +// script stays standalone (no Composer autoloader needed). +require_once $packageRoot . '/src/Registry/RegistryEntry.php'; + +use DrupalQuick\Registry\RegistryEntry; + // ------------------------------------------------------------------ Arguments $opts = [ @@ -102,39 +108,68 @@ function http_get(string $url, string $token = ''): array { } /** - * Normalises a git remote URL to an https web URL without the .git suffix. + * Parses YAML text via the best available backend, or NULL when none exists. + * + * The script itself stays dependency-free: it prefers the yaml PECL extension + * (enabled in CI via setup-php `extensions: yaml`), falls back to Symfony Yaml + * when an autoloader is reachable (cwd or this package), and otherwise returns + * NULL — the caller then skips recipe.yml-derived metadata with a warning + * rather than failing the whole build. */ -function normalize_git_url(string $url): string { - $url = trim($url); - // git@github.com:Org/repo.git → https://github.com/Org/repo - if (preg_match('#^git@([^:]+):(.+?)(?:\.git)?$#', $url, $m)) { - return "https://{$m[1]}/{$m[2]}"; +function parse_yaml(string $text): ?array { + if (function_exists('yaml_parse')) { + $data = @yaml_parse($text); + return is_array($data) ? $data : null; + } + static $autoloaded = false; + if (!$autoloaded) { + foreach ([getcwd() . '/vendor/autoload.php', dirname(__DIR__) . '/vendor/autoload.php'] as $autoload) { + if (is_file($autoload)) { + require_once $autoload; + break; + } + } + $autoloaded = true; + } + if (class_exists(\Symfony\Component\Yaml\Yaml::class)) { + try { + $data = \Symfony\Component\Yaml\Yaml::parse($text); + return is_array($data) ? $data : null; + } catch (\Throwable) { + return null; + } } - return preg_replace('#\.git$#', '', $url); + return null; } /** - * Builds a registry entry from a decoded composer.json, or NULL if the package - * is not a recipe that opts into the catalog. + * Parses a recipe.yml text, warning once when no YAML backend is available. + * + * Options are then omitted from the affected entries rather than failing the + * whole build. */ -function entry_from_composer(?array $composer, string $url): ?array { - if (!$composer || ($composer['type'] ?? '') !== 'drupal-recipe') { +function parse_recipe_yaml(?string $text): ?array { + if ($text === null) { return null; } - $meta = $composer['extra']['dq']['recipe'] ?? null; - if (!is_array($meta) || empty($meta['key'])) { - return null; + $data = parse_yaml($text); + if ($data === null) { + static $warned = false; + if (!$warned) { + fwrite(STDERR, "⚠️ No YAML backend (yaml ext or Symfony Yaml) — recipe options will be omitted from the registry.\n"); + $warned = true; + } } - return [ - 'key' => (string) $meta['key'], - 'entry' => [ - 'label' => (string) ($meta['label'] ?? $meta['key']), - 'package' => (string) ($composer['name'] ?? ''), - 'url' => $url, - ], - ]; + return $data; } +/** + * Builds a registry entry from a decoded composer.json, or NULL if the package + * is not a recipe that opts into the catalog. + */ +// Entry building itself lives in DrupalQuick\Registry\RegistryEntry (required +// above) so it is unit-testable; see tests/Unit/Registry/RegistryEntryTest.php. + // ------------------------------------------------------------------ Collect $registry = []; @@ -165,7 +200,9 @@ foreach ($opts['paths'] as $path) { } $composer = json_decode(file_get_contents($composerFile), true); $remote = @shell_exec('git -C ' . escapeshellarg($path) . ' remote get-url origin 2>/dev/null') ?: ''; - $add(entry_from_composer($composer, normalize_git_url($remote)), $path); + $recipeFile = rtrim($path, '/') . '/recipe.yml'; + $recipeYaml = is_file($recipeFile) ? file_get_contents($recipeFile) : null; + $add(RegistryEntry::fromComposer($composer, RegistryEntry::normalizeGitUrl($remote), parse_recipe_yaml($recipeYaml)), $path); } // 2. GitHub org enumeration. @@ -192,7 +229,12 @@ if ($opts['org'] !== '') { continue; } $composer = json_decode($cBody, true); - $add(entry_from_composer($composer, $repo['html_url'] ?? ''), $full); + // Fetch recipe.yml only for actual recipe packages (saves API calls). + $recipeYaml = null; + if (($composer['type'] ?? '') === 'drupal-recipe') { + [$recipeYaml] = http_get("https://raw.githubusercontent.com/{$full}/{$branch}/recipe.yml", $opts['token']); + } + $add(RegistryEntry::fromComposer($composer, $repo['html_url'] ?? '', parse_recipe_yaml($recipeYaml)), $full); } $page++; } while (count($repos) === 100); diff --git a/composer.json b/composer.json index ee1b22f..588b08d 100644 --- a/composer.json +++ b/composer.json @@ -21,5 +21,23 @@ "psr-4": { "DrupalQuick\\": "src/" } + }, + "repositories": [ + { + "type": "vcs", + "url": "https://github.com/Drupal-Quick/dq-starterkit", + "_comment": "Root-only (ignored when this package is a dependency); lets `composer install` resolve the starterkit for development." + } + ], + "require-dev": { + "phpunit/phpunit": "^10.5" + }, + "autoload-dev": { + "psr-4": { + "DrupalQuick\\Tests\\": "tests/" + } + }, + "scripts": { + "test": "phpunit" } -} \ No newline at end of file +} diff --git a/docs/presets.md b/docs/presets.md index 5b207b6..3527446 100644 --- a/docs/presets.md +++ b/docs/presets.md @@ -91,6 +91,34 @@ chosen preset ← presets/overrides.css (theme_design from config.dq.yml) `overrides.css` persists, so it survives re-skinning: `npm run preset corporate` keeps the user's `theme_design` tweaks on top of the new preset. +## The content-scale contract + +Recipes ship their own templates, yet a preset's type scale and negative space +must reach them — an airy preset must make *article titles and project titles +alike* larger and looser without either recipe knowing which preset runs. The +mechanism is a small **contract vocabulary** on top of the color/font tokens: + +| Token | Utility | Meaning | +| --- | --- | --- | +| `--text-title` (+ `--line-height`) | `text-title` | content-item titles in listings | +| `--text-meta` (+ `--line-height`) | `text-meta` | dates, captions, keyword lists | +| `--spacing-flow` | `gap-flow`, `py-flow`, … | space between content units | +| `--spacing-row` | `py-row` | vertical rhythm of a list row | + +Two rules make it work: + +- **Preset authors:** every preset MUST define the full contract (it is part of + "a preset is a complete token set"). Differentiate through it — `geometric` + ships larger `--text-title` and a wider `--spacing-flow`. +- **Recipe authors:** templates MUST use the contract utilities for type scale + and inter-unit spacing — never raw `text-sm` / `gap-6`. Colors already flow + through `text-ink` / `text-muted` / `bg-rule` etc. Anything hardcoded is + invisible to presets. + +`theme_design` can override contract tokens too: a key like `text_title` maps +to `--text-title` via the generic kebab rule, so +`theme_design: { text_title: "1.25rem" }` works with no special casing. + ## Assets (self-hosted fonts, on demand) The directory form lets a preset self-host webfonts **without committing any diff --git a/docs/workflow.md b/docs/workflow.md index 791d839..9d1a8b1 100644 --- a/docs/workflow.md +++ b/docs/workflow.md @@ -45,17 +45,34 @@ ddev composer exec -- dq-init --interactive ddev composer exec -- dq-init --ddev ``` -Edit `config.dq.yml` to set your site name, theme machine name, preset, and recipes. +Edit `config.dq.yml` to set your site name, theme machine name, preset, page +layout (`sidebar` | `single`), and recipes. Everything has a sane default — an +untouched file scaffolds a complete site. -A recipe entry can be a registry key, a core/contrib path, or an inline package spec for a one-off recipe with no registry edit: +A recipe entry can be a registry key, a core/contrib path, a key with an +`options:` map (the recipe's native inputs — `dq-install` writes each fetched +recipe's options back here as a commented block, ready to uncomment), or an +inline package spec for a one-off recipe: ```yaml recipes: - "core/recipes/standard" - - "blog" + - name: "blog" + options: + items_per_page: 10 - { package: "you/recipe-x", url: "https://github.com/you/recipe-x" } ``` +Optionally compose the front page from the blocks your recipes advertise +(otherwise the recipes' own front page stands — e.g. the blog's `/writing`): + +```yaml +homepage: + blocks: # display order = list order + - "blog/recent" + - "project/grid" +``` + Available registry keys out of the box: `blog`, `project`. See `templates/recipe-registry.json` for the full catalog and [docs/recipe-registry.md](recipe-registry.md) for how the registry works. --- @@ -68,6 +85,8 @@ ddev composer exec dq-install Reads `config.dq.yml`, registers any recipe VCS repos in `composer.json`, and `composer require`s each recipe package. `core-recipe-unpack` unpacks them into `recipes/`. +Afterwards it writes each recipe's available options — read from the now-unpacked `recipe.yml` — back into `config.dq.yml` as a **commented block under that recipe's entry**, so you can uncomment (remove the leading `# `) and edit to override a default. Nothing is enabled until you do; defaults apply otherwise. Pass `dq-install --exclude-options` to skip the rewrite and just list the options in the terminal. + > **Local dev note:** if recipe/theme packages are on your local machine rather than published to a Git remote, add them as path repos in `composer.json` before this step. VCS repos (GitHub) require DDEV to have GitHub auth — run `ddev auth ssh` first. --- @@ -81,12 +100,13 @@ ddev drush dq:scaffold This single command: 1. Installs Drupal (`drush site:install`) -2. Generates a real theme from the starterkit (`drupal generate-theme`) +2. Generates a real theme from the starterkit (`drupal generate-theme`) and bakes the chosen page-shell layout into it (the shell template is yours to edit afterwards) 3. Writes any `theme_design` overrides from `config.dq.yml` to `presets/overrides.css` -4. Applies each recipe in order (`drush recipe …`), injecting recipe `theme-assets/` into the theme -5. Installs deps and applies the chosen design preset — `npm install && npm run preset` — which fetches any preset fonts and builds the theme (unless `build: false`) +4. Applies each recipe in order (`drush recipe …` — passing any `options:` as native recipe `--input`s), injecting recipe `theme-assets/` into the theme +5. Composes the homepage when `homepage.blocks` is set: places the chosen recipe blocks and points the front page at the dedicated `/home` view 6. Applies any `recipe_config` overrides -7. Rebuilds caches +7. Installs deps and applies the chosen design preset — `npm install && npm run preset` — which fetches any preset fonts and builds the theme (unless `build: false`) +8. Rebuilds caches The site is live at `https://mysite.ddev.site`. diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..72b7ef6 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,22 @@ + + + + + + tests/Unit + + + + + src + + + diff --git a/src/Config/PresetDiscovery.php b/src/Config/PresetDiscovery.php new file mode 100644 index 0000000..48dd7fe --- /dev/null +++ b/src/Config/PresetDiscovery.php @@ -0,0 +1,42 @@ +, options: {...}} — the options form; options map to + * the recipe's native inputs (recipe.yml `input:` declares them, with + * defaults, so options are always optional); + * - {package: ..., url: ..., options?: {...}} — an inline package spec. + * + * Pure logic shared by ScaffoldCommand and the bin/ scripts; covered by + * tests/Unit/Config/RecipeEntryTest.php. + */ +final class RecipeEntry { + + /** + * Normalizes an entry to [reference, options]. + * + * The returned reference is what resolvePath()/packageFor() accept (the + * string, or the inline spec minus options). + */ + public static function normalize($recipe): array { + if (!is_array($recipe)) { + return [$recipe, []]; + } + $options = $recipe['options'] ?? []; + if (isset($recipe['name'])) { + return [$recipe['name'], is_array($options) ? $options : []]; + } + unset($recipe['options']); + return [$recipe, is_array($options) ? $options : []]; + } + + /** + * Returns the Composer package name for a normalized reference, or NULL for + * a core/contrib path. Accepts a registry key (string) or an inline spec + * (['package' => …, 'url' => …]). + */ + public static function packageFor($ref, array $registry): ?string { + if (is_array($ref)) { + return $ref['package'] ?? NULL; + } + return $registry[$ref]['package'] ?? NULL; + } + +} diff --git a/src/Config/RecipeOptions.php b/src/Config/RecipeOptions.php new file mode 100644 index 0000000..f4b759e --- /dev/null +++ b/src/Config/RecipeOptions.php @@ -0,0 +1,137 @@ + $inputs + * The recipe.yml `input:` block. + * + * @return string[] + */ + public static function activeLines(array $inputs): array { + $lines = [' options:']; + foreach ($inputs as $name => $def) { + $type = $def['data_type'] ?? 'string'; + $default = $def['default']['value'] ?? NULL; + if (is_bool($default)) { + $value = $default ? 'true' : 'false'; + } + elseif ($default === NULL) { + $value = '~'; + } + elseif (in_array($type, ['integer', 'float'], TRUE) && is_numeric($default)) { + $value = (string) $default; + } + else { + $value = '"' . $default . '"'; + } + $line = " {$name}: {$value}"; + $desc = trim((string) ($def['description'] ?? '')); + if ($desc !== '') { + $line .= " # {$desc}"; + } + $lines[] = $line; + } + return $lines; + } + + /** + * Injects a recipe's options as a COMMENTED block under its entry in the + * raw config.dq.yml lines, in place. + * + * A bare-string entry ("- blog") is promoted to the mapping form + * ("- name: blog") so the uncommented options attach to it. Idempotent: an + * entry that already has options (active or commented) is left untouched, + * preserving any user edits. + * + * @param string[] $lines + * The config file, split into lines. + * @param string $key + * The recipe's config key (registry key or path). + * @param string[] $activeLines + * Option lines as they'd read uncommented (see activeLines()). + * + * @return string[] + */ + public static function injectCommented(array $lines, string $key, array $activeLines): array { + // Bound the recipes: block — from `recipes:` to the next top-level key + // (a line starting with a non-space, non-# character). Indented entries + // and any injected `#`-comment lines stay inside the block. + $start = NULL; + foreach ($lines as $i => $l) { + if (preg_match('/^recipes:\s*$/', $l)) { + $start = $i; + break; + } + } + if ($start === NULL) { + return $lines; + } + $end = count($lines); + for ($i = $start + 1; $i < count($lines); $i++) { + if (preg_match('/^[^\s#]/', $lines[$i])) { + $end = $i; + break; + } + } + + // Find this recipe's entry line (string or mapping form). + $qk = preg_quote($key, '/'); + $entryIdx = NULL; + $isString = FALSE; + for ($i = $start + 1; $i < $end; $i++) { + if (preg_match('/^\s*-\s*["\']?' . $qk . '["\']?\s*$/', $lines[$i])) { + $entryIdx = $i; + $isString = TRUE; + break; + } + if (preg_match('/^\s*-\s*name:\s*["\']?' . $qk . '["\']?\s*$/', $lines[$i])) { + $entryIdx = $i; + break; + } + } + if ($entryIdx === NULL) { + return $lines; + } + + // Idempotency: bail if the entry already carries options, active or + // commented. Scan its own following lines only (stop at the next sibling + // entry or a blank line). + for ($j = $entryIdx + 1; $j < $end; $j++) { + if (trim($lines[$j]) === '' || preg_match('/^\s*-\s/', $lines[$j])) { + break; + } + if (preg_match('/^\s*#?\s*options:\s*$/', $lines[$j])) { + return $lines; + } + } + + if ($isString) { + $lines[$entryIdx] = preg_replace('/-\s*["\']?' . $qk . '["\']?/', '- name: "' . $key . '"', $lines[$entryIdx], 1); + } + $commented = array_map(static fn (string $l): string => '# ' . $l, $activeLines); + array_splice($lines, $entryIdx + 1, 0, $commented); + return $lines; + } + +} diff --git a/src/Drush/Commands/ScaffoldCommand.php b/src/Drush/Commands/ScaffoldCommand.php index aab1ef3..852d5ba 100644 --- a/src/Drush/Commands/ScaffoldCommand.php +++ b/src/Drush/Commands/ScaffoldCommand.php @@ -4,6 +4,8 @@ use Drupal\Component\Serialization\Json; use Drupal\Component\Serialization\Yaml; +use DrupalQuick\Config\PresetDiscovery; +use DrupalQuick\Config\RecipeEntry; use Drush\Drush; use Drush\Style\DrushStyle; use Symfony\Component\Console\Attribute\AsCommand; @@ -75,7 +77,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int // Discover presets from the installed starterkit (package.json "dq"), // falling back to scanning its presets/ directory. $starterkitDir = $this->drupalRoot() . '/themes/contrib/dq_starterkit'; - [$availablePresets, $defaultPreset] = $this->discoverPresets($starterkitDir); + [$availablePresets, $defaultPreset] = PresetDiscovery::discover($starterkitDir); $config['theme']['preset'] = $this->io->choice( 'Which design preset would you like to apply?', @@ -116,6 +118,7 @@ private function runBuild(array $config, array $registry): int { $themeName = $config['theme']['machine_name'] ?? NULL; $themeTitle = $config['theme']['title'] ?? 'Custom Theme'; $themePreset = $config['theme']['preset'] ?? NULL; + $themeLayout = $config['theme']['layout'] ?? NULL; $themeBuild = $config['theme']['build'] ?? TRUE; $recipes = $config['recipes'] ?? []; $parameters = $config['parameters'] ?? []; @@ -176,6 +179,29 @@ private function runBuild(array $config, array $registry): int { // is set after recipes (step 3.5) so recipe-set defaults do not clobber it. Drush::drush(Drush::aliasManager()->getSelf(), 'theme:enable', [$themeName], ['yes' => TRUE])->mustRun(); + // Bake the chosen page-shell arrangement. Layout is a scaffold-time + // choice, not a runtime setting: the starterkit ships the default shell + // (templates/includes/page-shell.html.twig — the sidebar arrangement, + // embedded by both page templates) plus one file per alternative + // (page-shell--.html.twig). The chosen variant replaces the + // shell, the unchosen ones are removed, and from then on the shell is + // ordinary Twig the user edits directly. Runs before the theme build, so + // only the chosen arrangement's classes are compiled. + $shellDir = "{$themeDir}/templates/includes"; + if ($themeLayout && $themeLayout !== 'sidebar') { + $variant = "{$shellDir}/page-shell--{$themeLayout}.html.twig"; + if (file_exists($variant)) { + $this->io->writeln("🧭 Baking the '{$themeLayout}' page shell..."); + copy($variant, "{$shellDir}/page-shell.html.twig"); + } + else { + $this->io->warning("Unknown layout '{$themeLayout}' — no page-shell--{$themeLayout}.html.twig in the starterkit. Keeping the default shell."); + } + } + foreach (glob("{$shellDir}/page-shell--*.html.twig") ?: [] as $variantFile) { + unlink($variantFile); + } + // Token application is deferred to the preset step (`npm run preset` in // step 5) — the single source of truth, which also serves re-skinning // after scaffold. Here we only translate config.dq.yml's theme_design into @@ -210,8 +236,9 @@ private function runBuild(array $config, array $registry): int { $umbrellaDir = $this->ensureUmbrellaModule(); $assembled = []; foreach ($recipes as $recipe) { - $path = $this->resolvePath($recipe, $registry); - if ($path !== $recipe && ($name = $this->assembleRecipeModule($path, $umbrellaDir))) { + [$ref] = RecipeEntry::normalize($recipe); + $path = $this->resolvePath($ref, $registry); + if ($path !== $ref && ($name = $this->assembleRecipeModule($path, $umbrellaDir))) { $assembled[] = $name; } } @@ -227,15 +254,32 @@ private function runBuild(array $config, array $registry): int { if (!empty($recipes)) { $this->io->writeln('📦 Applying Drupal recipes...'); foreach ($recipes as $recipe) { - $path = $this->resolvePath($recipe, $registry); + [$ref, $options] = RecipeEntry::normalize($recipe); + $path = $this->resolvePath($ref, $registry); // Managed recipes (a registry key or inline spec) resolve to a recipes/ // path; core/contrib path strings pass through unchanged. - $managed = ($path !== $recipe); + $managed = ($path !== $ref); if ($managed) { - $label = is_array($recipe) ? ($recipe['package'] ?? 'recipe') : $recipe; + $label = is_array($ref) ? ($ref['package'] ?? 'recipe') : $ref; $this->io->writeln(" Resolved '{$label}' → {$path}"); } - Drush::drush(Drush::aliasManager()->getSelf(), 'recipe', [$path], ['yes' => TRUE])->mustRun(); + + // The entry's options map to native recipe inputs: each becomes + // --input=.= (core prefixes input names with + // the recipe directory's basename). Passed as extra args because the + // site-process option serializer can't repeat an option; Symfony + // parses them as options regardless of position. Unset options fall + // back to the recipe.yml input defaults. + $args = [$path]; + foreach ($options as $key => $value) { + if (!is_scalar($value)) { + $this->io->warning("Skipping non-scalar option '{$key}' for recipe '{$path}'."); + continue; + } + $value = is_bool($value) ? ($value ? '1' : '0') : (string) $value; + $args[] = '--input=' . basename($path) . ".{$key}={$value}"; + } + Drush::drush(Drush::aliasManager()->getSelf(), 'recipe', $args, ['yes' => TRUE])->mustRun(); // Inject theme assets when the recipe ships a theme-assets/ directory // (copyThemeAssets no-ops when it doesn't — no registry flag needed). @@ -251,6 +295,109 @@ private function runBuild(array $config, array $registry): int { Drush::drush(Drush::aliasManager()->getSelf(), 'config:set', ['system.theme', 'default', $themeName], ['yes' => TRUE])->mustRun(); } + // 3.7. Compose the homepage from recipe-advertised blocks. Each + // homepage.blocks entry is "/", resolved through the + // registry's blocks metadata (recipes advertise placeable blocks in their + // composer.json extra.dq.recipe.blocks — capabilities ship with the recipe; + // *placement* is selected here). Blocks land in the theme's content region, + // restricted to , ordered by the list. The result is ordinary block + // config — rearrange or remove later at /admin/structure/block. When + // homepage.blocks is absent, whatever front page the recipes set (e.g. the + // blog's /writing) stands. + $homepageBlocks = $config['homepage']['blocks'] ?? []; + if ($themeName && $homepageBlocks) { + $this->io->writeln('🏠 Composing the homepage from recipe blocks...'); + // Block placement needs the block module (standard ships it; minimal + // alone does not). pm:install is a no-op when it is already enabled. + Drush::drush(Drush::aliasManager()->getSelf(), 'pm:install', ['block'], ['yes' => TRUE])->mustRun(); + + $weight = -10; + foreach ($homepageBlocks as $entry) { + [$recipeKey, $blockKey] = array_pad(explode('/', (string) $entry, 2), 2, ''); + $blockMeta = $registry[$recipeKey]['blocks'][$blockKey] ?? NULL; + if (!$blockMeta || empty($blockMeta['plugin'])) { + $this->io->warning("Unknown homepage block '{$entry}' — recipe '{$recipeKey}' does not advertise '{$blockKey}'. Skipped."); + continue; + } + $values = [ + 'id' => preg_replace('/[^a-z0-9_]+/', '_', strtolower("{$themeName}_dq_{$recipeKey}_{$blockKey}")), + 'theme' => $themeName, + 'region' => 'content', + 'plugin' => $blockMeta['plugin'], + 'weight' => $weight++, + 'settings' => [ + 'label' => (string) ($blockMeta['label'] ?? $entry), + 'label_display' => '0', + ], + 'visibility' => [ + 'request_path' => [ + 'id' => 'request_path', + 'negate' => FALSE, + 'pages' => '', + ], + ], + ]; + // Created via php:eval — block config entities cannot be built with + // plain config:set, and the scaffold already shells out per step. + $code = sprintf('\Drupal\block\Entity\Block::create(%s)->save();', var_export($values, TRUE)); + Drush::drush(Drush::aliasManager()->getSelf(), 'php:eval', [$code])->mustRun(); + $this->io->writeln(" Placed {$entry} → {$values['id']}"); + } + + // The composed homepage lives at /home — a dedicated, always-empty view + // page created for the purpose (the chosen blocks render there via + // visibility). A view rather than a custom route keeps this + // config-only: after scaffold the admin can repoint system.site + // page.front, or edit/delete the view like any other. Its bundle filter + // matches nothing, so the view contributes no rows of its own. + Drush::drush(Drush::aliasManager()->getSelf(), 'pm:install', ['views'], ['yes' => TRUE])->mustRun(); + $home = <<<'PHP' +if (!\Drupal\views\Entity\View::load('dq_home')) { + \Drupal\views\Entity\View::create([ + 'id' => 'dq_home', + 'label' => 'Home', + 'description' => 'Empty page at /home hosting the composed homepage blocks (created by dq:scaffold).', + 'base_table' => 'node_field_data', + 'base_field' => 'nid', + 'display' => [ + 'default' => [ + 'id' => 'default', + 'display_plugin' => 'default', + 'display_title' => 'Default', + 'position' => 0, + 'display_options' => [ + 'access' => ['type' => 'perm', 'options' => ['perm' => 'access content']], + 'cache' => ['type' => 'tag', 'options' => []], + 'pager' => ['type' => 'none', 'options' => ['offset' => 0]], + 'filters' => [ + 'type' => [ + 'id' => 'type', 'table' => 'node_field_data', 'field' => 'type', + 'entity_type' => 'node', 'entity_field' => 'type', 'plugin_id' => 'bundle', + 'value' => ['dq_home_none' => 'dq_home_none'], + ], + ], + 'title' => '', + ], + ], + 'page_1' => [ + 'id' => 'page_1', + 'display_plugin' => 'page', + 'display_title' => 'Page', + 'position' => 1, + 'display_options' => ['path' => 'home'], + ], + ], + ])->save(); +} +PHP; + Drush::drush(Drush::aliasManager()->getSelf(), 'php:eval', [$home])->mustRun(); + Drush::drush(Drush::aliasManager()->getSelf(), 'config:set', ['system.site', 'page.front', '/home'], ['yes' => TRUE])->mustRun(); + // Router rebuild so the new /home path routes (the scaffold's final + // cache rebuild would also cover it, but blocks placed above should be + // verifiable immediately). + Drush::drush(Drush::aliasManager()->getSelf(), 'cache:rebuild')->mustRun(); + } + // 4. Post-recipe config overrides. if (!empty($parameters['recipe_config'])) { $this->io->writeln('⚙️ Applying recipe_config parameter overrides...'); @@ -344,7 +491,7 @@ private function registry(): array { * (recipes/); core/contrib path strings pass through. */ private function resolvePath($recipe, array $registry): string { - $package = $this->recipePackage($recipe, $registry); + $package = RecipeEntry::packageFor($recipe, $registry); if ($package === NULL) { // A core/contrib path string (e.g. core/recipes/standard) — unchanged. return $recipe; @@ -354,18 +501,6 @@ private function resolvePath($recipe, array $registry): string { return getcwd() . '/recipes/' . $short; } - /** - * Returns the Composer package name for a recipe entry, or NULL for a - * core/contrib path. Accepts a registry key (string) or an inline spec - * (['package' => …, 'url' => …]). - */ - private function recipePackage($recipe, array $registry): ?string { - if (is_array($recipe)) { - return $recipe['package'] ?? NULL; - } - return $registry[$recipe]['package'] ?? NULL; - } - /** * Copies a recipe's theme-assets/ into the generated theme. */ @@ -498,35 +633,6 @@ private function removeDirectory(string $dir): void { rmdir($dir); } - /** - * Discovers presets from the installed starterkit's package.json "dq". - * - * The "dq" block (presets + defaultPreset) is the single source of truth and - * travels intact through generate-theme, so there is no need to scan presets/; - * a hardcoded set is the last resort if the manifest can't be read. The same - * contract is read by bin/dq-init and scripts/preset.mjs — keep them in step. - * Returns [string[] $names, string $default]. - */ - private function discoverPresets(string $starterkitDir): array { - $names = []; - $default = 'minimal'; - - $pkg = "{$starterkitDir}/package.json"; - if (file_exists($pkg)) { - $meta = json_decode(file_get_contents($pkg), TRUE) ?: []; - $names = $meta['dq']['presets'] ?? []; - $default = $meta['dq']['defaultPreset'] ?? $default; - } - - if (!$names) { - $names = ['minimal', 'corporate']; - } - if (!in_array($default, $names, TRUE)) { - $default = $names[0]; - } - return [$names, $default]; - } - /** * Maps a config.dq.yml theme_design key to a Tailwind v4 @theme token name. */ diff --git a/src/Registry/RegistryEntry.php b/src/Registry/RegistryEntry.php new file mode 100644 index 0000000..3b013b6 --- /dev/null +++ b/src/Registry/RegistryEntry.php @@ -0,0 +1,96 @@ + (string) ($meta['label'] ?? $meta['key']), + 'package' => (string) ($composer['name'] ?? ''), + 'url' => $url, + ]; + // Placeable blocks the recipe advertises for homepage composition + // (config.dq.yml homepage.blocks entries "/"). + // Carried verbatim: { : { plugin, label } }. + if (!empty($meta['blocks']) && is_array($meta['blocks'])) { + $entry['blocks'] = $meta['blocks']; + } + // User-tunable options, summarised from the recipe's own recipe.yml input + // block — lets pre-fetch consumers (dq-init --interactive) surface them. + if ($recipeData !== NULL) { + $options = self::optionsFromRecipe($recipeData); + if ($options) { + $entry['options'] = $options; + } + } + return [ + 'key' => (string) $meta['key'], + 'entry' => $entry, + ]; + } + + /** + * Extracts the wizard-facing options summary from parsed recipe.yml data. + * + * Maps the recipe's native `input:` definitions to + * { name: { type, description, default } }. + */ + public static function optionsFromRecipe(array $recipeData): array { + $options = []; + foreach (($recipeData['input'] ?? []) as $name => $definition) { + if (!is_array($definition)) { + continue; + } + $options[$name] = [ + 'type' => (string) ($definition['data_type'] ?? 'string'), + 'description' => (string) ($definition['description'] ?? ''), + 'default' => $definition['default']['value'] ?? NULL, + ]; + } + return $options; + } + + /** + * Normalises a git remote URL to an https web URL without the .git suffix. + */ + public static function normalizeGitUrl(string $url): string { + $url = trim($url); + // git@github.com:Org/repo.git → https://github.com/Org/repo + if (preg_match('#^git@([^:]+):(.+?)(?:\.git)?$#', $url, $m)) { + return "https://{$m[1]}/{$m[2]}"; + } + return (string) preg_replace('#\.git$#', '', $url); + } + +} diff --git a/templates/config.dq.yml b/templates/config.dq.yml index 72faf3d..3feefbd 100644 --- a/templates/config.dq.yml +++ b/templates/config.dq.yml @@ -13,6 +13,12 @@ theme: # Available out of the box: minimal, corporate. Omit to use the starterkit's # default. Change it later with `npm run preset ` inside the theme. preset: "minimal" + # Page-shell arrangement, baked into the theme at scaffold time: "sidebar" + # (site title atop a vertical left menu — the default) or "single" (one + # column, title + horizontal menu along the top). Afterwards the shell is an + # ordinary template you own — edit templates/includes/page-shell.html.twig + # in the generated theme to change or restyle the layout. + layout: "sidebar" # Set to false to skip `npm install && npm run build` after theme generation. # Useful when deferring the build to a CI step or building manually later. build: true @@ -20,14 +26,32 @@ theme: # Each recipe entry can be: # - a short key from the registry (templates/recipe-registry.json), e.g. "blog" # - a core/contrib path, e.g. "core/recipes/standard" +# - a key plus per-recipe options (the recipe's native inputs — all optional, +# with sane defaults): +# - name: "blog" +# options: +# items_per_page: 10 # - an inline package spec for an ad-hoc recipe (no registry edit needed): # - { package: "you/recipe-x", url: "https://github.com/you/recipe-x" } # Run `composer exec dq-install` after editing — it fetches any package recipes -# (registry keys or inline specs) before `drush dq:scaffold`. +# (registry keys or inline specs) before `drush dq:scaffold`, and writes each +# recipe's available options here as a commented block under its entry, ready to +# uncomment. Pass --exclude-options to just list them in the terminal instead. recipes: - "core/recipes/standard" - "blog" +# Homepage composition (optional). Pick which recipe-advertised blocks make up +# the front page, in order — each entry is "/" (recipes +# advertise their placeable blocks; `dq-install` and the registry list them). +# When set, the front page becomes the composed blocks; when omitted, whatever +# front page the recipes configure stands (e.g. the blog's /writing). Placed +# blocks are ordinary block config — edit later at /admin/structure/block. +# homepage: +# blocks: +# - "blog/recent" +# - "project/grid" + parameters: # Override the chosen preset's design tokens. Persisted to presets/overrides.css # and layered over the preset (preset <- these), so they drive utilities and diff --git a/templates/recipe-registry.json b/templates/recipe-registry.json index afc3797..5d55b9c 100644 --- a/templates/recipe-registry.json +++ b/templates/recipe-registry.json @@ -2,11 +2,30 @@ "blog": { "label": "Blog — Keywords taxonomy + entity reference field on Article", "package": "drupal-quick/recipe-blog", - "url": "https://github.com/Drupal-Quick/recipe-blog" + "url": "https://github.com/Drupal-Quick/recipe-blog", + "blocks": { + "recent": { + "plugin": "views_block:writing-block_1", + "label": "Recent writing" + } + }, + "options": { + "items_per_page": { + "type": "integer", + "description": "How many articles the writing view lists per page.", + "default": 30 + } + } }, "project": { "label": "Project — content type (page + link + thumbnail) and a projects grid view", "package": "drupal-quick/recipe-project", - "url": "https://github.com/Drupal-Quick/recipe-project" + "url": "https://github.com/Drupal-Quick/recipe-project", + "blocks": { + "grid": { + "plugin": "views_block:projects-block_1", + "label": "Projects grid" + } + } } } diff --git a/tests/Unit/Config/PresetDiscoveryTest.php b/tests/Unit/Config/PresetDiscoveryTest.php new file mode 100644 index 0000000..903c887 --- /dev/null +++ b/tests/Unit/Config/PresetDiscoveryTest.php @@ -0,0 +1,43 @@ +assertSame(['minimal', 'corporate', 'geometric'], $names); + $this->assertSame('geometric', $default); + } + + public function testFallsBackWhenPackageJsonIsMissing(): void { + [$names, $default] = PresetDiscovery::discover(self::FIXTURES . '/does-not-exist'); + + $this->assertSame(['minimal', 'corporate'], $names); + $this->assertSame('minimal', $default); + } + + public function testFallsBackWhenTheDqBlockIsAbsent(): void { + [$names, $default] = PresetDiscovery::discover(self::FIXTURES . '/no-dq-block'); + + $this->assertSame(['minimal', 'corporate'], $names); + $this->assertSame('minimal', $default); + } + + public function testUnknownDefaultIsCoercedToTheFirstPreset(): void { + [$names, $default] = PresetDiscovery::discover(self::FIXTURES . '/bad-default'); + + $this->assertSame(['alpha', 'beta'], $names); + $this->assertSame('alpha', $default); + } + +} diff --git a/tests/Unit/Config/RecipeEntryTest.php b/tests/Unit/Config/RecipeEntryTest.php new file mode 100644 index 0000000..3990137 --- /dev/null +++ b/tests/Unit/Config/RecipeEntryTest.php @@ -0,0 +1,50 @@ +assertSame(['blog', []], RecipeEntry::normalize('blog')); + $this->assertSame(['core/recipes/standard', []], RecipeEntry::normalize('core/recipes/standard')); + } + + public function testNameFormCarriesItsOptions(): void { + $this->assertSame( + ['blog', ['items_per_page' => 10]], + RecipeEntry::normalize(['name' => 'blog', 'options' => ['items_per_page' => 10]]) + ); + } + + public function testNameFormWithoutOptions(): void { + $this->assertSame(['blog', []], RecipeEntry::normalize(['name' => 'blog'])); + } + + public function testInlineSpecKeepsPackageAndUrlButNotOptions(): void { + $entry = ['package' => 'you/recipe-x', 'url' => 'https://example.com/x', 'options' => ['a' => 1]]; + + [$ref, $options] = RecipeEntry::normalize($entry); + + $this->assertSame(['package' => 'you/recipe-x', 'url' => 'https://example.com/x'], $ref); + $this->assertSame(['a' => 1], $options); + } + + public function testNonArrayOptionsAreCoercedToEmpty(): void { + $this->assertSame(['blog', []], RecipeEntry::normalize(['name' => 'blog', 'options' => 'oops'])); + } + + public function testPackageForResolvesRegistryKeyInlineSpecAndPath(): void { + $registry = ['blog' => ['package' => 'drupal-quick/recipe-blog']]; + + $this->assertSame('drupal-quick/recipe-blog', RecipeEntry::packageFor('blog', $registry)); + $this->assertSame('you/recipe-x', RecipeEntry::packageFor(['package' => 'you/recipe-x'], $registry)); + $this->assertNull(RecipeEntry::packageFor('core/recipes/standard', $registry)); + } + +} diff --git a/tests/Unit/Config/RecipeOptionsTest.php b/tests/Unit/Config/RecipeOptionsTest.php new file mode 100644 index 0000000..bc3f401 --- /dev/null +++ b/tests/Unit/Config/RecipeOptionsTest.php @@ -0,0 +1,186 @@ + [ + 'data_type' => 'integer', + 'description' => 'How many articles the writing view lists per page.', + 'default' => ['source' => 'value', 'value' => 30], + ], + ]; + + private const CONFIG = << ['data_type' => 'integer', 'default' => ['value' => 30], 'description' => 'Rows.'], + 'title' => ['data_type' => 'string', 'default' => ['value' => 'Writing']], + 'enabled' => ['data_type' => 'boolean', 'default' => ['value' => TRUE]], + 'off' => ['data_type' => 'boolean', 'default' => ['value' => FALSE]], + 'unset' => ['data_type' => 'string'], + ]); + + $this->assertSame([ + ' options:', + ' per_page: 30 # Rows.', + ' title: "Writing"', + ' enabled: true', + ' off: false', + ' unset: ~', + ], $lines); + } + + public function testActiveLinesUncommentedParseUnderAMappingEntry(): void { + // The rendered lines must be valid YAML at their stated indentation when + // placed under a `- name:` entry — that is the whole uncomment contract. + $yaml = "recipes:\n - name: \"blog\"\n" + . implode("\n", RecipeOptions::activeLines(self::INPUTS)) . "\n"; + $parsed = Yaml::parse($yaml); + + $this->assertSame(30, $parsed['recipes'][0]['options']['items_per_page']); + } + + // ---------------------------------------------------- injectCommented() + + public function testInjectPromotesAStringEntryToMappingForm(): void { + $lines = $this->inject(self::CONFIG); + + $this->assertContains(' - name: "blog"', $lines); + $this->assertNotContains(' - "blog"', $lines); + // Siblings are untouched. + $this->assertContains(' - "core/recipes/standard"', $lines); + $this->assertContains(' - "project"', $lines); + } + + public function testInjectPlacesCommentedBlockDirectlyUnderTheEntry(): void { + $lines = $this->inject(self::CONFIG); + $at = array_search(' - name: "blog"', $lines, TRUE); + + $this->assertSame('# options:', $lines[$at + 1]); + $this->assertSame('# items_per_page: 30 # How many articles the writing view lists per page.', $lines[$at + 2]); + $this->assertSame(' - "project"', $lines[$at + 3]); + } + + public function testFileStillParsesWithTheBlockCommented(): void { + $parsed = Yaml::parse(implode("\n", $this->inject(self::CONFIG))); + + $this->assertSame(['name' => 'blog'], $parsed['recipes'][1]); + } + + public function testUncommentingTheBlockYieldsValidOptions(): void { + $out = implode("\n", $this->inject(self::CONFIG)); + // What a user does: strip the leading "# " from the injected lines only. + $out = preg_replace('/^# ( options:| \w)/m', '$1', $out); + $parsed = Yaml::parse($out); + + $this->assertSame(30, $parsed['recipes'][1]['options']['items_per_page']); + } + + public function testInjectUnderAnExistingMappingEntry(): void { + $config = "recipes:\n - name: \"blog\"\n\nparameters:\n x: 1\n"; + $lines = $this->inject($config); + + $this->assertSame('# options:', $lines[2]); + } + + public function testInjectIsIdempotentOnItsOwnOutput(): void { + $once = $this->inject(self::CONFIG); + + $this->assertSame($once, $this->injectLines($once)); + } + + public function testInjectPreservesUserEditedActiveOptions(): void { + $edited = $this->inject(self::CONFIG); + // The user uncommented and changed the value. + $edited = str_replace( + ['# options:', '# items_per_page: 30 # How many articles the writing view lists per page.'], + [' options:', ' items_per_page: 5'], + $edited + ); + + $this->assertSame($edited, $this->injectLines($edited)); + } + + public function testUnknownRecipeKeyLeavesLinesUntouched(): void { + $lines = explode("\n", self::CONFIG); + + $this->assertSame($lines, RecipeOptions::injectCommented($lines, 'nope', RecipeOptions::activeLines(self::INPUTS))); + } + + public function testMissingRecipesBlockLeavesLinesUntouched(): void { + $lines = ['site:', ' name: "x"']; + + $this->assertSame($lines, RecipeOptions::injectCommented($lines, 'blog', RecipeOptions::activeLines(self::INPUTS))); + } + + public function testEntryFollowedByTopLevelCommentBlockStillMatches(): void { + // The shipped template has a col-0 "# homepage:" comment block right after + // the last recipe entry — comment lines must not end the recipes block. + $config = <<inject($config); + $at = array_search(' - name: "blog"', $lines, TRUE); + + $this->assertNotFalse($at); + $this->assertSame('# options:', $lines[$at + 1]); + // The comment block after it is untouched. + $this->assertContains('# homepage:', $lines); + } + + /** + * @return string[] + */ + private function inject(string $config): array { + return $this->injectLines(explode("\n", $config)); + } + + /** + * @param string[] $lines + * @return string[] + */ + private function injectLines(array $lines): array { + return RecipeOptions::injectCommented($lines, 'blog', RecipeOptions::activeLines(self::INPUTS)); + } + +} diff --git a/tests/Unit/Registry/RegistryEntryTest.php b/tests/Unit/Registry/RegistryEntryTest.php new file mode 100644 index 0000000..96fe073 --- /dev/null +++ b/tests/Unit/Registry/RegistryEntryTest.php @@ -0,0 +1,92 @@ + 'drupal-quick/recipe-blog', + 'type' => 'drupal-recipe', + 'extra' => [ + 'dq' => [ + 'recipe' => [ + 'key' => 'blog', + 'label' => 'Blog — Keywords on Article', + 'blocks' => [ + 'recent' => ['plugin' => 'views_block:writing-block_1', 'label' => 'Recent writing'], + ], + ], + ], + ], + ]; + + public function testBuildsAFullEntry(): void { + $built = RegistryEntry::fromComposer(self::COMPOSER, 'https://github.com/Drupal-Quick/recipe-blog'); + + $this->assertSame('blog', $built['key']); + $this->assertSame([ + 'label' => 'Blog — Keywords on Article', + 'package' => 'drupal-quick/recipe-blog', + 'url' => 'https://github.com/Drupal-Quick/recipe-blog', + 'blocks' => [ + 'recent' => ['plugin' => 'views_block:writing-block_1', 'label' => 'Recent writing'], + ], + ], $built['entry']); + } + + public function testNonRecipePackagesAreSkipped(): void { + $this->assertNull(RegistryEntry::fromComposer(['name' => 'x/y', 'type' => 'drupal-theme'], '')); + $this->assertNull(RegistryEntry::fromComposer(NULL, '')); + } + + public function testRecipesWithoutCatalogMetadataAreSkipped(): void { + $this->assertNull(RegistryEntry::fromComposer(['name' => 'x/y', 'type' => 'drupal-recipe'], '')); + } + + public function testLabelFallsBackToTheKey(): void { + $composer = ['name' => 'x/y', 'type' => 'drupal-recipe', 'extra' => ['dq' => ['recipe' => ['key' => 'y']]]]; + + $this->assertSame('y', RegistryEntry::fromComposer($composer, '')['entry']['label']); + } + + public function testRecipeInputsAreSummarisedIntoOptions(): void { + $recipeData = [ + 'input' => [ + 'items_per_page' => [ + 'data_type' => 'integer', + 'description' => 'How many articles per page.', + 'default' => ['source' => 'value', 'value' => 30], + ], + ], + ]; + + $built = RegistryEntry::fromComposer(self::COMPOSER, '', $recipeData); + + $this->assertSame([ + 'items_per_page' => [ + 'type' => 'integer', + 'description' => 'How many articles per page.', + 'default' => 30, + ], + ], $built['entry']['options']); + } + + public function testRecipeWithoutInputsGetsNoOptionsKey(): void { + $built = RegistryEntry::fromComposer(self::COMPOSER, '', ['name' => 'Blog']); + + $this->assertArrayNotHasKey('options', $built['entry']); + } + + public function testNormalizeGitUrl(): void { + $this->assertSame('https://github.com/Org/repo', RegistryEntry::normalizeGitUrl('git@github.com:Org/repo.git')); + $this->assertSame('https://github.com/Org/repo', RegistryEntry::normalizeGitUrl("https://github.com/Org/repo.git\n")); + $this->assertSame('https://github.com/Org/repo', RegistryEntry::normalizeGitUrl('https://github.com/Org/repo')); + } + +} diff --git a/tests/fixtures/starterkits/bad-default/package.json b/tests/fixtures/starterkits/bad-default/package.json new file mode 100644 index 0000000..dcd26b2 --- /dev/null +++ b/tests/fixtures/starterkits/bad-default/package.json @@ -0,0 +1,7 @@ +{ + "name": "dq_starterkit", + "dq": { + "defaultPreset": "missing", + "presets": ["alpha", "beta"] + } +} diff --git a/tests/fixtures/starterkits/no-dq-block/package.json b/tests/fixtures/starterkits/no-dq-block/package.json new file mode 100644 index 0000000..f1f4d9c --- /dev/null +++ b/tests/fixtures/starterkits/no-dq-block/package.json @@ -0,0 +1,3 @@ +{ + "name": "dq_starterkit" +} diff --git a/tests/fixtures/starterkits/with-manifest/package.json b/tests/fixtures/starterkits/with-manifest/package.json new file mode 100644 index 0000000..1db2533 --- /dev/null +++ b/tests/fixtures/starterkits/with-manifest/package.json @@ -0,0 +1,7 @@ +{ + "name": "dq_starterkit", + "dq": { + "defaultPreset": "geometric", + "presets": ["minimal", "corporate", "geometric"] + } +}