Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 46 additions & 4 deletions .agents/skills/dq-add-recipe/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<recipe-dir>.<name>=<value>`. 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 `"<key>/<block-key>"` (e.g. `blog/recent`); the plugin
id for a views block is `views_block:<view_id>-<display_id>`. 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
Expand All @@ -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.
41 changes: 41 additions & 0 deletions .agents/skills/dq-conventions/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: <key>` / `options:` entries, passed by
`dq:scaffold` as `--input=<recipe-dir>.<name>=<value>`. 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--<layout>.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
(`"<recipe>/<block>"`, order = weight) become block config in the content
region, `<front>`-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

Expand All @@ -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`.
3 changes: 3 additions & 0 deletions .github/workflows/recipe-registry.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 25 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -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
7 changes: 6 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,9 @@
.DS_Store

# Claude Code local settings (machine-specific)
.claude/settings.local.json
.claude/settings.local.json

# Dev dependencies (unit tests) — resolved fresh; not shipped
/vendor/
composer.lock
.phpunit.cache/
25 changes: 22 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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 <name>`.
- 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 <name>` — 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.

---
Expand All @@ -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.
Expand Down
93 changes: 74 additions & 19 deletions bin/dq-init
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand All @@ -150,21 +149,76 @@ $registry = file_exists($registryFile)
? json_decode(file_get_contents($registryFile), true)
: [];

$recipeLines = [' - "core/recipes/standard"'];
$recipeLines = [' - "core/recipes/standard"'];
$chosenBlocks = [];

if (!empty($registry)) {
$options = [];
foreach ($registry as $key => $info) {
$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.
Expand All @@ -180,10 +234,11 @@ theme:
machine_name: "{$themeMachine}"
title: "{$themeTitle}"
preset: "{$themePreset}"
layout: "{$themeLayout}"

recipes:
{$recipesBlock}

{$homepageBlock}
parameters:
theme_design:
primary_color: "#10b981"
Expand Down
Loading
Loading