diff --git a/bin/dq-install b/bin/dq-install index 39749d4..8e7b6ca 100644 --- a/bin/dq-install +++ b/bin/dq-install @@ -40,11 +40,13 @@ 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. +// The injection logic lives in plain classes in this package — required directly +// so they resolve regardless of the consumer autoloader's state. require_once $packageRoot . '/src/Config/RecipeOptions.php'; +require_once $packageRoot . '/src/Config/RecipeBlocks.php'; use DrupalQuick\Config\RecipeOptions; +use DrupalQuick\Config\RecipeBlocks; use Symfony\Component\Yaml\Yaml; use Symfony\Component\Yaml\Exception\ParseException; use Symfony\Component\Process\Process; @@ -213,16 +215,20 @@ foreach ($toRequire as $package) { echo "\n✅ Recipe packages installed.\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. +// --------------------------------------------------------------- Options + Block catalog +// Two write-back operations on config.dq.yml; we do them in a single read/write +// pass to avoid multiple file round-trips. // -// 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. +// Options: each managed recipe may declare user-tunable inputs (recipe.yml +// `input:`). Default: write them as a commented block under the recipe entry. +// --exclude-options: print to terminal only, don't touch the file. // -// Collect [config key => input definitions] for registry recipes with inputs. +// Block catalog: each registry recipe may advertise placeable blocks in its +// composer.json extra.dq.recipe.blocks. Always written as a commented +// `# ── Available recipe blocks` section so the user knows what keys to use +// in homepage: > blocks: (and future placement targets). + +// Collect options from unpacked recipe.yml files. $recipeOptions = []; foreach ($recipes as $recipe) { if (is_array($recipe) && isset($recipe['name'])) { @@ -230,10 +236,10 @@ foreach ($recipes as $recipe) { } elseif (is_string($recipe)) { $key = $recipe; } else { - continue; // inline package spec — no stable key to locate in the file + continue; } if (!isset($registry[$key]['package'])) { - continue; // core/contrib path, or a key with no registry entry + continue; } $pkg = $registry[$key]['package']; $short = ($pos = strpos($pkg, '/')) !== false ? substr($pkg, $pos + 1) : $pkg; @@ -251,6 +257,28 @@ foreach ($recipes as $recipe) { } } +// Collect blocks from the registry for all installed registry recipes. +$recipeBlocks = []; +foreach ($recipes as $recipe) { + if (is_array($recipe) && isset($recipe['name'])) { + $key = $recipe['name']; + } elseif (is_string($recipe)) { + $key = $recipe; + } else { + continue; + } + if (!isset($registry[$key]['blocks'])) { + continue; + } + foreach ($registry[$key]['blocks'] as $blockKey => $blockMeta) { + $recipeBlocks["{$key}/{$blockKey}"] = [ + 'label' => $blockMeta['label'] ?? $blockKey, + 'plugin' => $blockMeta['plugin'] ?? '', + ]; + } +} + +// Print options to terminal when --exclude-options is set. 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"; @@ -263,20 +291,41 @@ if (!empty($recipeOptions) && $excludeOptions) { 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; +} + +// Single read → inject options → inject block catalog → single write. +$needsOptionWrite = !empty($recipeOptions) && !$excludeOptions; +$needsBlockWrite = !empty($recipeBlocks); + +if ($needsOptionWrite || $needsBlockWrite) { + $lines = explode("\n", file_get_contents($configFile)); + $before = $lines; + + $injectedOptions = []; + if ($needsOptionWrite) { + foreach ($recipeOptions as $key => $inputs) { + $prev = $lines; + $lines = RecipeOptions::injectCommented($lines, $key, RecipeOptions::activeLines($inputs)); + if ($lines !== $prev) { + $injectedOptions[] = $key; + } } } - if ($injected) { + + if ($needsBlockWrite) { + $lines = RecipeBlocks::injectCatalog($lines, $recipeBlocks); + } + + if ($lines !== $before) { 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"; + if ($injectedOptions) { + echo "\nℹ️ Wrote commented recipe options into config.dq.yml for: " . implode(', ', $injectedOptions) . ".\n"; + echo " Uncomment (remove the leading `# `) under each recipe to override its defaults.\n"; + } + if ($needsBlockWrite) { + echo "\nℹ️ Wrote recipe block catalog into config.dq.yml.\n"; + echo " Uncomment the homepage: section (remove `# `) to compose the front page.\n"; + } } } diff --git a/docs/workflow.md b/docs/workflow.md index 9d1a8b1..bd7af6c 100644 --- a/docs/workflow.md +++ b/docs/workflow.md @@ -85,7 +85,10 @@ 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. +Afterwards it rewrites `config.dq.yml` in a single pass: + +- **Recipe options** — each recipe's user-tunable inputs (from its `recipe.yml input:` block) are written as a commented block directly under that recipe's entry. Uncomment (remove the leading `# `) and edit to override a default; every input has a default so an untouched file still works. Pass `--exclude-options` to print them in the terminal instead and leave the file untouched. +- **Block catalog** — a `# ── Available recipe blocks` section is injected (or updated) listing every block your installed recipes advertise, keyed as `/`. The section includes the `homepage:` snippet to uncomment and edit. It also serves as a reference for future placement targets (sidebars, banners, etc.); for now only `homepage:` is acted on by `dq:scaffold`. > **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. diff --git a/src/Config/RecipeBlocks.php b/src/Config/RecipeBlocks.php new file mode 100644 index 0000000..4ee2f70 --- /dev/null +++ b/src/Config/RecipeBlocks.php @@ -0,0 +1,147 @@ + blocks: (and any + * future placement targets — sidebars, banners, etc.). + * + * Pure string/array logic; covered by tests/Unit/Config/RecipeBlocksTest.php. + */ +final class RecipeBlocks { + + // Marker used to detect a previously-injected catalog (enables idempotency). + private const MARKER = '# ── Available recipe blocks'; + + /** + * Builds the full commented catalog block as an array of lines. + * + * @param array $blocks + * Flat map of "/" => ['label' => ..., 'plugin' => ...]. + * + * @return string[] + */ + public static function commentedCatalog(array $blocks): array { + if (empty($blocks)) { + return []; + } + + $maxLen = max(array_map('strlen', array_keys($blocks))); + + $lines = [ + self::MARKER . ' ──────────────────────────────────────────────────────', + '# Blocks advertised by your installed recipes. Use these keys in', + '# homepage: > blocks: to compose the front page. In the future they may', + '# also drive placement for other pages or regions (sidebars, banners, etc.).', + '#', + ]; + + foreach ($blocks as $key => $meta) { + $pad = str_repeat(' ', $maxLen - strlen($key)); + $label = $meta['label'] ?? $key; + $plugin = $meta['plugin'] ?? ''; + $entry = "# {$key}{$pad} — {$label}"; + if ($plugin !== '') { + $entry .= " ({$plugin})"; + } + $lines[] = $entry; + } + + $lines[] = '#'; + $lines[] = '# Uncomment and edit to activate. Listed order = display order.'; + $lines[] = '# Placed blocks are ordinary Drupal config, editable at /admin/structure/block.'; + $lines[] = '#'; + $lines[] = '# homepage:'; + $lines[] = '# blocks:'; + + foreach (array_keys($blocks) as $key) { + $lines[] = "# - \"{$key}\""; + } + + return $lines; + } + + /** + * Injects or replaces the commented block catalog in the config file lines. + * + * - Skips entirely if homepage: is already active (user has configured it). + * - Replaces the MARKER-based catalog from a prior run (idempotent). + * - Replaces the template's generic commented homepage: block on first run. + * - Inserts before `parameters:` or `static:` when no existing section exists. + * + * @param string[] $lines + * @param array $blocks + * + * @return string[] + */ + public static function injectCatalog(array $lines, array $blocks): array { + if (empty($blocks)) { + return $lines; + } + + $catalog = self::commentedCatalog($blocks); + + // If homepage: is already active (not commented), leave the file alone. + foreach ($lines as $line) { + if (preg_match('/^homepage:\s*$/', $line)) { + return $lines; + } + } + + $total = count($lines); + $sectionStart = NULL; + $sectionEnd = NULL; + + for ($i = 0; $i < $total; $i++) { + $isMarker = str_starts_with($lines[$i], self::MARKER); + $isTemplate = (bool) preg_match('/^# homepage:\s*$/', $lines[$i]); + + if (!$isMarker && !$isTemplate) { + continue; + } + + // For the template form, scan backward through contiguous # lines to + // include the prose description that precedes `# homepage:`. + $sectionStart = $i; + if ($isTemplate) { + while ($sectionStart > 0 && str_starts_with($lines[$sectionStart - 1], '#')) { + $sectionStart--; + } + } + + // Scan forward to the first non-comment, non-blank line. + for ($j = $sectionStart + 1; $j < $total; $j++) { + if (!str_starts_with($lines[$j], '#') && trim($lines[$j]) !== '') { + $sectionEnd = $j; + break; + } + } + $sectionEnd = $sectionEnd ?? $total; + break; + } + + if ($sectionStart !== NULL) { + // Replace the existing section; preserve blank-line separation from the + // following key by appending an empty line to the catalog. + array_splice($lines, $sectionStart, $sectionEnd - $sectionStart, array_merge($catalog, [''])); + return $lines; + } + + // No existing section — insert before `parameters:` or `static:`. + foreach ($lines as $i => $line) { + if (preg_match('/^(parameters|static):\s*/', $line)) { + array_splice($lines, $i, 0, array_merge($catalog, [''])); + return $lines; + } + } + + // Fallback: append at the end. + return array_merge($lines, [''], $catalog); + } + +} diff --git a/templates/config.dq.yml b/templates/config.dq.yml index 3feefbd..d69fd06 100644 --- a/templates/config.dq.yml +++ b/templates/config.dq.yml @@ -41,12 +41,11 @@ 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 composition (optional). After `dq-install` runs, this section is +# replaced with a block catalog listing every block your installed recipes +# advertise, together with the homepage: snippet to uncomment and edit. The +# catalog also serves as a reference for future placement targets (sidebars, +# banners, etc.) — for now, only homepage: is acted on by dq:scaffold. # homepage: # blocks: # - "blog/recent" diff --git a/tests/Unit/Config/RecipeBlocksTest.php b/tests/Unit/Config/RecipeBlocksTest.php new file mode 100644 index 0000000..3e5a149 --- /dev/null +++ b/tests/Unit/Config/RecipeBlocksTest.php @@ -0,0 +1,208 @@ + ['label' => 'Recent writing', 'plugin' => 'views_block:writing-block_1'], + 'project/grid' => ['label' => 'Projects grid', 'plugin' => 'views_block:projects-block_1'], + ]; + + // ------------------------------------------------------------------ catalog + + public function testCommentedCatalogEmptyReturnsEmpty(): void { + $this->assertSame([], RecipeBlocks::commentedCatalog([])); + } + + public function testCommentedCatalogContainsMarker(): void { + $lines = RecipeBlocks::commentedCatalog(self::$twoBlocks); + $this->assertStringStartsWith('# ── Available recipe blocks', $lines[0]); + } + + public function testCommentedCatalogListsBlockKeys(): void { + $lines = RecipeBlocks::commentedCatalog(self::$twoBlocks); + $joined = implode("\n", $lines); + $this->assertStringContainsString('blog/recent', $joined); + $this->assertStringContainsString('project/grid', $joined); + } + + public function testCommentedCatalogListsLabels(): void { + $lines = RecipeBlocks::commentedCatalog(self::$twoBlocks); + $joined = implode("\n", $lines); + $this->assertStringContainsString('Recent writing', $joined); + $this->assertStringContainsString('Projects grid', $joined); + } + + public function testCommentedCatalogListsPlugins(): void { + $lines = RecipeBlocks::commentedCatalog(self::$twoBlocks); + $joined = implode("\n", $lines); + $this->assertStringContainsString('views_block:writing-block_1', $joined); + } + + public function testCommentedCatalogEndsWithCommentedHomepageYaml(): void { + $lines = RecipeBlocks::commentedCatalog(self::$twoBlocks); + $this->assertSame('# - "project/grid"', end($lines)); + } + + public function testCommentedCatalogSingleBlock(): void { + $blocks = ['blog/recent' => ['label' => 'Recent writing', 'plugin' => 'views_block:writing-block_1']]; + $lines = RecipeBlocks::commentedCatalog($blocks); + $joined = implode("\n", $lines); + $this->assertStringContainsString('# - "blog/recent"', $joined); + $this->assertStringNotContainsString('project/grid', $joined); + } + + // ------------------------------------------------------------------ inject: skip cases + + public function testInjectCatalogEmptyBlocksReturnsUnchanged(): void { + $lines = ['recipes:', ' - "blog"', '', 'parameters:']; + $this->assertSame($lines, RecipeBlocks::injectCatalog($lines, [])); + } + + public function testInjectCatalogSkipsWhenHomepageAlreadyActive(): void { + $lines = [ + 'recipes:', + ' - "blog"', + '', + 'homepage:', + ' blocks:', + ' - "blog/recent"', + '', + 'parameters:', + ]; + $result = RecipeBlocks::injectCatalog($lines, self::$twoBlocks); + $this->assertSame($lines, $result); + } + + // ------------------------------------------------------------------ inject: template form + + public function testInjectCatalogReplacesTemplatePlaceholder(): void { + $lines = [ + 'recipes:', + ' - "blog"', + '', + '# Homepage composition (optional). Pick which blocks...', + '# When set, the front page becomes the composed blocks.', + '# homepage:', + '# blocks:', + '# - "blog/recent"', + '# - "project/grid"', + '', + 'parameters:', + ]; + $result = RecipeBlocks::injectCatalog($lines, self::$twoBlocks); + $joined = implode("\n", $result); + + // The marker should now be present. + $this->assertStringContainsString('── Available recipe blocks', $joined); + // The original prose comment should be gone. + $this->assertStringNotContainsString('Homepage composition (optional)', $joined); + // The block keys should appear in the catalog. + $this->assertStringContainsString('blog/recent', $joined); + $this->assertStringContainsString('project/grid', $joined); + // parameters: must still be present. + $this->assertStringContainsString('parameters:', $joined); + } + + public function testInjectCatalogPreservesBlankLineSeparationAfterReplacement(): void { + $lines = [ + 'recipes:', + ' - "blog"', + '', + '# homepage:', + '# blocks:', + '', + 'parameters:', + ]; + $result = RecipeBlocks::injectCatalog($lines, self::$twoBlocks); + // Find the parameters: line and check the line before it is blank. + $paramIdx = array_search('parameters:', $result, TRUE); + $this->assertNotFalse($paramIdx); + $this->assertSame('', $result[$paramIdx - 1]); + } + + // ------------------------------------------------------------------ inject: idempotency + + public function testInjectCatalogIsIdempotent(): void { + $lines = [ + 'recipes:', + ' - "blog"', + '', + 'parameters:', + ]; + $once = RecipeBlocks::injectCatalog($lines, self::$twoBlocks); + $twice = RecipeBlocks::injectCatalog($once, self::$twoBlocks); + $this->assertSame($once, $twice); + } + + public function testInjectCatalogUpdatesOnNewRecipe(): void { + $initial = [ + 'recipes:', + ' - "blog"', + '', + 'parameters:', + ]; + $oneBlock = [ + 'blog/recent' => ['label' => 'Recent writing', 'plugin' => 'views_block:writing-block_1'], + ]; + $afterFirst = RecipeBlocks::injectCatalog($initial, $oneBlock); + + // Now a second recipe is added and dq-install runs again. + $result = RecipeBlocks::injectCatalog($afterFirst, self::$twoBlocks); + $joined = implode("\n", $result); + + // Both blocks should now appear. + $this->assertStringContainsString('blog/recent', $joined); + $this->assertStringContainsString('project/grid', $joined); + + // The MARKER should appear exactly once. + $this->assertSame(1, substr_count($joined, '── Available recipe blocks')); + } + + // ------------------------------------------------------------------ inject: fresh insert + + public function testInjectCatalogInsertsBeforeParameters(): void { + $lines = [ + 'recipes:', + ' - "blog"', + '', + 'parameters:', + ' theme_design:', + ]; + $result = RecipeBlocks::injectCatalog($lines, self::$twoBlocks); + $joined = implode("\n", $result); + $markerPos = strpos($joined, '── Available recipe blocks'); + $paramPos = strpos($joined, 'parameters:'); + $this->assertLessThan($paramPos, $markerPos); + } + + public function testInjectCatalogInsertsBeforeStatic(): void { + $lines = [ + 'recipes:', + ' - "blog"', + '', + 'static:', + ' target: netlify', + ]; + $result = RecipeBlocks::injectCatalog($lines, self::$twoBlocks); + $joined = implode("\n", $result); + $markerPos = strpos($joined, '── Available recipe blocks'); + $staticPos = strpos($joined, 'static:'); + $this->assertLessThan($staticPos, $markerPos); + } + + public function testInjectCatalogAppendsWhenNoAnchor(): void { + $lines = ['recipes:', ' - "blog"']; + $result = RecipeBlocks::injectCatalog($lines, self::$twoBlocks); + $this->assertGreaterThan(count($lines), count($result)); + $this->assertStringContainsString('── Available recipe blocks', implode("\n", $result)); + } + +} diff --git a/tools/generator/README.md b/tools/generator/README.md new file mode 100644 index 0000000..f623401 --- /dev/null +++ b/tools/generator/README.md @@ -0,0 +1,48 @@ +# config.dq.yml generator (POC) + +A static form + live-preview page that composes a complete `config.dq.yml`, +ready to paste (or download) into a project before `drush dq:scaffold`. It +streamlines what `dq-init --interactive` asks and pre-answers what `dq-install` +would write back (commented recipe options, the block catalog). + +Internal testing tool for now; the eventual home is quickthe.me. + +## Stack + +- Single static page: `index.html` + `app.js` + `catalog.js`. No build step. +- [Alpine.js](https://alpinejs.dev) (CDN) for reactivity, Tailwind v4 browser + CDN for the form chrome. Needs network for the two CDN scripts. +- The preview pane is styled **only** by `--pv-*` CSS variables set from the + selected preset's real token values (transcribed into `catalog.js` from + `dq-starterkit/presets/*.css`), so switching preset/layout/overrides + restyles the mock homepage with the actual design tokens. + +## Running it + +Copy (or symlink) this directory into any web-served docroot. For the smoke +site: + +```bash +cp -R tools/generator ~/dev/dq-smoke/web/generator +# → https://dq-smoke.ddev.site/generator/ +``` + +Opening `index.html` directly from disk also works (clipboard copy requires a +secure context, so prefer the served form). + +## Maintenance contract + +The YAML emitter in `app.js` reproduces the canonical output of three sources +and must be kept in sync with them (until the registry emits a shared JSON +contract both PHP and this page consume): + +- `templates/config.dq.yml` — section comments, verbatim +- `src/Config/RecipeOptions::activeLines()` — option line format +- `src/Config/RecipeBlocks::commentedCatalog()` — the block catalog section + +`catalog.js` hardcodes the recipe/preset metadata that `recipe-registry.json` +and the starterkit `package.json` provide at runtime; regenerate it by hand +when those change. + +@todo Dummy content per recipe should eventually ship in the recipe itself +(a sample-content metadata block) rather than in `catalog.js`. diff --git a/tools/generator/app.js b/tools/generator/app.js new file mode 100644 index 0000000..9ff5944 --- /dev/null +++ b/tools/generator/app.js @@ -0,0 +1,301 @@ +/** + * Alpine component for the config.dq.yml generator POC. + * + * The YAML emitter reproduces the canonical file byte-for-byte where it can: + * section comments come verbatim from templates/config.dq.yml, the commented + * option blocks mirror RecipeOptions::activeLines(), and the block catalog + * mirrors RecipeBlocks::commentedCatalog(). If those change, change this too + * (until the registry emits a shared JSON contract both sides consume). + */ +function dqGenerator() { + return { + open: 'site', + tab: 'preview', + copied: false, + + site: { name: 'My Tailored Drupal Site', admin_user: 'admin', admin_pass: '' }, + theme: { machine_name: 'my_custom_theme', title: 'My Custom Theme', preset: 'minimal', layout: 'sidebar', build: true }, + design: { primary_color: '', font_family: '' }, + selected: Object.fromEntries(DQ_CATALOG.recipes.map(r => [r.key, false])), + options: Object.fromEntries(DQ_CATALOG.recipes.map(r => + [r.key, Object.fromEntries(r.options.map(o => [o.name, String(o.default)]))] + )), + homepage: [], + extras: DQ_CATALOG.extras.map(e => ({ ...e, enabled: false, value: e.sample })), + + // ---------------------------------------------------------------- UI + + toggleSection(s) { this.open = this.open === s ? '' : s; }, + + get catalog() { return DQ_CATALOG; }, + + recipe(key) { return DQ_CATALOG.recipes.find(r => r.key === key); }, + + get selectedRecipes() { + return DQ_CATALOG.recipes.filter(r => this.selected[r.key]); + }, + + onRecipeToggle(key) { + if (!this.selected[key]) { + this.homepage = this.homepage.filter(id => id.split('/')[0] !== key); + } + }, + + // ------------------------------------------------------------ blocks + + get availableBlocks() { + const out = []; + for (const r of this.selectedRecipes) { + for (const b of r.blocks) { + out.push({ id: `${r.key}/${b.key}`, recipe: r.key, ...b }); + } + } + return out; + }, + + get unplacedBlocks() { + return this.availableBlocks.filter(b => !this.homepage.includes(b.id)); + }, + + blockMeta(id) { + return this.availableBlocks.find(b => b.id === id); + }, + + addBlock(id) { this.homepage.push(id); }, + removeBlock(id) { this.homepage = this.homepage.filter(x => x !== id); }, + moveBlock(i, dir) { + const j = i + dir; + if (j < 0 || j >= this.homepage.length) return; + const h = [...this.homepage]; + [h[i], h[j]] = [h[j], h[i]]; + this.homepage = h; + }, + + // ----------------------------------------------------------- preview + + get previewTokens() { + const p = DQ_CATALOG.presets.find(p => p.key === this.theme.preset) || DQ_CATALOG.presets[0]; + const t = { ...p.tokens }; + if (this.design.primary_color) t.primary = this.design.primary_color; + if (this.design.font_family) t.font = this.design.font_family; + return t; + }, + + get previewStyle() { + const t = this.previewTokens; + return `--pv-primary:${t.primary};--pv-secondary:${t.secondary};--pv-accent:${t.accent};` + + `--pv-paper:${t.paper};--pv-ink:${t.ink};--pv-muted:${t.muted};--pv-rule:${t.rule};` + + `--pv-font:${t.font};--pv-title:${t.title};--pv-title-lh:${t.titleLh};` + + `--pv-meta:${t.meta};--pv-meta-lh:${t.metaLh};--pv-flow:${t.flow};--pv-row:${t.row}`; + }, + + get previewNav() { + const items = ['Home']; + if (this.selected.blog) items.push('Writing'); + if (this.selected.project) items.push('Projects'); + items.push('About'); + return items; + }, + + // What the front page shows when homepage: is not composed. + get fallbackFront() { + if (this.selected.blog) return 'writing'; + return 'welcome'; + }, + + articlesFor() { + const all = this.recipe('blog').dummy.articles; + const n = parseInt(this.options.blog.items_per_page, 10); + return all.slice(0, Math.max(1, Math.min(isNaN(n) ? all.length : n, all.length))); + }, + + // -------------------------------------------------------------- YAML + + yaml() { + const L = []; + this.emitSite(L); + L.push(''); + this.emitTheme(L); + L.push(''); + this.emitRecipes(L); + L.push(''); + this.emitBlockCatalog(L); + this.emitHomepage(L); + this.emitParameters(L); + return L.join('\n') + '\n'; + }, + + q(s) { return '"' + String(s).replace(/"/g, '\\"') + '"'; }, + + emitSite(L) { + L.push('site:'); + L.push(` name: ${this.q(this.site.name)}`); + L.push(` admin_user: ${this.q(this.site.admin_user)}`); + if (this.site.admin_pass) { + L.push(' # Set explicitly — lives here in plaintext until `dq:cleanup` redacts it'); + L.push(' # on archive.'); + L.push(` admin_pass: ${this.q(this.site.admin_pass)}`); + } else { + L.push(' # Leave admin_pass unset to have a strong password generated and shown once'); + L.push(' # during `drush dq:scaffold`. Set it explicitly only if you must — it then'); + L.push(' # lives here in plaintext until `dq:cleanup` redacts it on archive.'); + L.push(' # admin_pass: "change-me"'); + } + }, + + emitTheme(L) { + L.push('theme:'); + L.push(` machine_name: ${this.q(this.theme.machine_name)}`); + L.push(` title: ${this.q(this.theme.title)}`); + L.push(' # A design preset shipped by the starterkit (see its package.json "dq.presets").'); + L.push(' # Available out of the box: minimal, corporate, geometric. Omit to use the'); + L.push(' # starterkit\'s default. Change it later with `npm run preset ` inside the theme.'); + L.push(` preset: ${this.q(this.theme.preset)}`); + L.push(' # Page-shell arrangement, baked into the theme at scaffold time: "sidebar"'); + L.push(' # (site title atop a vertical left menu — the default) or "single" (one'); + L.push(' # column, title + horizontal menu along the top). Afterwards the shell is an'); + L.push(' # ordinary template you own — edit templates/includes/page-shell.html.twig'); + L.push(' # in the generated theme to change or restyle the layout.'); + L.push(` layout: ${this.q(this.theme.layout)}`); + L.push(' # Set to false to skip `npm install && npm run build` after theme generation.'); + L.push(' # Useful when deferring the build to a CI step or building manually later.'); + L.push(` build: ${this.theme.build}`); + }, + + emitRecipes(L) { + L.push('# Each recipe entry can be:'); + L.push('# - a short key from the registry (templates/recipe-registry.json), e.g. "blog"'); + L.push('# - a core/contrib path, e.g. "core/recipes/standard"'); + L.push('# - a key plus per-recipe options (the recipe\'s native inputs — all optional,'); + L.push('# with sane defaults):'); + L.push('# - name: "blog"'); + L.push('# options:'); + L.push('# items_per_page: 10'); + L.push('# - an inline package spec for an ad-hoc recipe (no registry edit needed):'); + L.push('# - { package: "you/recipe-x", url: "https://github.com/you/recipe-x" }'); + L.push('# Run `composer exec dq-install` after editing — it fetches any package recipes'); + L.push('# (registry keys or inline specs) before `drush dq:scaffold`, and writes each'); + L.push('# recipe\'s available options here as a commented block under its entry, ready to'); + L.push('# uncomment. Pass --exclude-options to just list them in the terminal instead.'); + L.push('recipes:'); + L.push(' - "core/recipes/standard"'); + for (const r of this.selectedRecipes) { + if (!r.options.length) { + L.push(` - ${this.q(r.key)}`); + continue; + } + const touched = r.options.some(o => String(this.options[r.key][o.name]) !== String(o.default)); + L.push(` - name: ${this.q(r.key)}`); + for (const line of this.optionLines(r, touched)) { + L.push(touched ? line : '# ' + line); + } + } + }, + + // Mirrors RecipeOptions::activeLines(): options: at 4 spaces, keys at 6, + // description as a trailing comment. + optionLines(r, useCurrent) { + const lines = [' options:']; + for (const o of r.options) { + const raw = useCurrent ? this.options[r.key][o.name] : o.default; + const value = o.type === 'integer' || o.type === 'float' ? String(raw) : this.q(raw); + let line = ` ${o.name}: ${value}`; + if (o.description) line += ` # ${o.description}`; + lines.push(line); + } + return lines; + }, + + // Mirrors RecipeBlocks::commentedCatalog(). + emitBlockCatalog(L) { + const blocks = this.availableBlocks; + if (!blocks.length) return; + const maxLen = Math.max(...blocks.map(b => b.id.length)); + L.push('# ── Available recipe blocks ' + '─'.repeat(54)); + L.push('# Blocks advertised by your installed recipes. Use these keys in'); + L.push('# homepage: > blocks: to compose the front page. In the future they may'); + L.push('# also drive placement for other pages or regions (sidebars, banners, etc.).'); + L.push('#'); + for (const b of blocks) { + const pad = ' '.repeat(maxLen - b.id.length); + L.push(`# ${b.id}${pad} — ${b.label} (${b.plugin})`); + } + L.push('#'); + L.push('# Uncomment and edit to activate. Listed order = display order.'); + L.push('# Placed blocks are ordinary Drupal config, editable at /admin/structure/block.'); + L.push('#'); + L.push('# homepage:'); + L.push('# blocks:'); + for (const b of blocks) { + L.push(`# - "${b.id}"`); + } + L.push(''); + }, + + emitHomepage(L) { + if (!this.homepage.length) return; + L.push('homepage:'); + L.push(' blocks:'); + for (const id of this.homepage) { + L.push(` - "${id}"`); + } + L.push(''); + }, + + emitParameters(L) { + const design = []; + if (this.design.primary_color) design.push(` primary_color: ${this.q(this.design.primary_color)}`); + if (this.design.font_family) design.push(` font_family: ${this.q(this.design.font_family)}`); + + const grouped = {}; + for (const e of this.extras) { + if (!e.enabled || e.value === '') continue; + (grouped[e.config] ??= []).push(e); + } + const hasExtras = Object.keys(grouped).length > 0; + if (!design.length && !hasExtras) return; + + L.push('parameters:'); + if (design.length) { + L.push(' # Override the chosen preset\'s design tokens. Persisted to presets/overrides.css'); + L.push(' # and layered over the preset (preset <- these), so they drive utilities and'); + L.push(' # are emitted as CSS variables. Conventional keys map to Tailwind tokens:'); + L.push(' # _color -> --color- (e.g. bg-primary, text-secondary, var())'); + L.push(' # font_family -> --font-sans'); + L.push(' # Other keys become -- theme tokens, usable as var(--key).'); + L.push(' theme_design:'); + L.push(...design); + } + if (hasExtras) { + if (design.length) L.push(''); + L.push(' # drush config:set calls applied after recipes finish.'); + L.push(' # Format: config-object-name → key (dot notation) → value'); + L.push(' recipe_config:'); + for (const [config, entries] of Object.entries(grouped)) { + L.push(` ${this.q(config)}:`); + for (const e of entries) { + L.push(` ${e.key}: ${this.q(e.value)}`); + } + } + } + L.push(''); + }, + + // ----------------------------------------------------------- actions + + async copyConfig() { + await navigator.clipboard.writeText(this.yaml()); + this.copied = true; + setTimeout(() => { this.copied = false; }, 1500); + }, + + downloadConfig() { + const blob = new Blob([this.yaml()], { type: 'text/yaml' }); + const a = document.createElement('a'); + a.href = URL.createObjectURL(blob); + a.download = 'config.dq.yml'; + a.click(); + URL.revokeObjectURL(a.href); + }, + }; +} diff --git a/tools/generator/catalog.js b/tools/generator/catalog.js new file mode 100644 index 0000000..b5bc836 --- /dev/null +++ b/tools/generator/catalog.js @@ -0,0 +1,162 @@ +/** + * Static catalog for the config.dq.yml generator POC. + * + * Hardcoded mirror of what the recipe registry + starterkit package.json will + * eventually provide dynamically (likely emitted by bin/dq-registry-build as a + * JSON blob both the PHP injectors and this generator consume). Preset token + * values are transcribed from dq-starterkit/presets/*.css so the preview pane + * renders with the real design tokens. + * + * @todo Dummy content should eventually ship inside each recipe (a + * sample-content metadata block) so the generator — and optionally the + * scaffold itself — can populate from the recipe rather than from here. + */ +window.DQ_CATALOG = { + + presets: [ + { + key: 'minimal', + label: 'Minimal', + description: 'Restrained editorial defaults — ink on paper, Inter.', + tokens: { + primary: '#111827', + secondary: '#4b5563', + accent: '#374151', + paper: 'oklch(0.99 0.002 80)', + ink: 'oklch(0.22 0.004 80)', + muted: 'oklch(0.55 0.004 80)', + rule: 'oklch(0.9 0.003 80)', + font: "'Inter', ui-sans-serif, system-ui, sans-serif", + title: '1rem', titleLh: '1.5rem', + meta: '0.875rem', metaLh: '1.25rem', + flow: '1.5rem', row: '0.5rem', + }, + }, + { + key: 'corporate', + label: 'Corporate', + description: 'Deep blue + teal, business-forward. Dense scale.', + tokens: { + primary: '#1e3a8a', + secondary: '#0d9488', + accent: '#f59e0b', + paper: 'oklch(0.99 0.002 80)', + ink: 'oklch(0.22 0.004 80)', + muted: 'oklch(0.55 0.004 80)', + rule: 'oklch(0.9 0.003 80)', + font: "'Helvetica Neue', Arial, sans-serif", + title: '1rem', titleLh: '1.5rem', + meta: '0.875rem', metaLh: '1.25rem', + flow: '1.25rem', row: '0.5rem', + }, + }, + { + key: 'geometric', + label: 'Geometric', + description: 'Geom (self-hosted webfont) with an indigo/pink accent. Airy scale.', + tokens: { + primary: '#0f172a', + secondary: '#6366f1', + accent: '#ec4899', + paper: 'oklch(0.99 0.002 80)', + ink: 'oklch(0.22 0.004 80)', + muted: 'oklch(0.55 0.004 80)', + rule: 'oklch(0.9 0.003 80)', + // Geom itself is fetched on demand at build time; the preview + // approximates with a geometric system stack. + font: "'Geom', 'Futura', 'Century Gothic', ui-sans-serif, sans-serif", + title: '1.125rem', titleLh: '1.75rem', + meta: '0.875rem', metaLh: '1.25rem', + flow: '2rem', row: '0.75rem', + }, + }, + ], + + recipes: [ + { + key: 'blog', + label: 'Blog', + description: 'Keywords taxonomy + entity reference field on Article, and a "writing" view listing articles by date at /writing (set as the front page).', + options: [ + { + name: 'items_per_page', + type: 'integer', + description: 'How many articles the writing view lists per page.', + default: 30, + }, + ], + blocks: [ + { key: 'recent', label: 'Recent writing', plugin: 'views_block:writing-block_1' }, + ], + dummy: { + articles: [ + { title: 'On shipping small', date: 'Jun 28, 2026' }, + { title: 'Recipes as composition units', date: 'Jun 21, 2026' }, + { title: 'A field guide to design tokens', date: 'Jun 14, 2026' }, + { title: 'Static first, dynamic when earned', date: 'Jun 02, 2026' }, + { title: 'Notes on the writing view', date: 'May 25, 2026' }, + ], + }, + }, + { + key: 'project', + label: 'Project', + description: 'A Project content type (basic page + link + thumbnail) and a "projects" view listing them as a thumbnail grid.', + options: [], + blocks: [ + { key: 'grid', label: 'Projects grid', plugin: 'views_block:projects-block_1' }, + ], + dummy: { + projects: [ + { title: 'Wayfarer', blurb: 'Trail maps for the impatient.' }, + { title: 'Ledger Lite', blurb: 'Plain-text accounting, visualized.' }, + { title: 'Fieldnotes', blurb: 'A pocket wiki for research trips.' }, + { title: 'Beacon', blurb: 'Uptime checks with taste.' }, + { title: 'Drift', blurb: 'Ambient soundscapes, generated.' }, + { title: 'Quickthe.me', blurb: 'This very generator, one day.' }, + ], + }, + }, + ], + + // Curated global config overrides (parameters: -> recipe_config). Config + // scoped to one recipe belongs in that recipe's options; these are + // site-wide values worth surfacing. + extras: [ + { + id: 'slogan', + config: 'system.site', + key: 'slogan', + label: 'Site slogan', + description: 'Shown wherever the theme prints the site slogan.', + sample: 'Scaffolded quickly, maintained sustainably.', + }, + { + id: 'mail', + config: 'system.site', + key: 'mail', + label: 'Site email address', + description: 'The from-address for automated site mail.', + sample: 'hello@example.com', + }, + { + id: 'register', + config: 'user.settings', + key: 'register', + label: 'Account registration', + description: 'Who can create accounts: visitors | admin_only | visitors_admin_approval', + sample: 'admin_only', + }, + ], + + // Font choices offered for the theme_design override (free text also works + // in the real config; this is the curated POC list). + fonts: [ + { value: '', label: 'No override (use preset font)' }, + { value: "'Inter', sans-serif", label: 'Inter' }, + { value: "'Helvetica Neue', Arial, sans-serif", label: 'Helvetica Neue' }, + { value: "'Geom', sans-serif", label: 'Geom' }, + { value: 'Georgia, serif', label: 'Georgia (serif)' }, + { value: 'ui-monospace, monospace', label: 'Monospace' }, + ], +}; diff --git a/tools/generator/index.html b/tools/generator/index.html new file mode 100644 index 0000000..d734f9b --- /dev/null +++ b/tools/generator/index.html @@ -0,0 +1,435 @@ + + + + + + Quick — config.dq.yml generator + + + + + + + + +
+ + + + + +
+ +
+
+ + +
+ + Static mock — real preset tokens, dummy content +
+ + +
+
+ + +
+ +
+
+ + + +
+
+
+ + +
+
+
+ +
+
+ + + +
+
+
+
+ + +
+

+    
+
+
+ + +