Skip to content
Open
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
95 changes: 72 additions & 23 deletions bin/dq-install
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -213,27 +215,31 @@ 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'])) {
$key = $recipe['name'];
} 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;
Expand All @@ -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";
Expand All @@ -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";
}
}
}

Expand Down
5 changes: 4 additions & 1 deletion docs/workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<recipe>/<block>`. 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.

Expand Down
147 changes: 147 additions & 0 deletions src/Config/RecipeBlocks.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
<?php

namespace DrupalQuick\Config;

/**
* Builds and injects the recipe block catalog into config.dq.yml.
*
* After dq-install fetches recipes, each registry-managed recipe may advertise
* placeable blocks in its composer.json extra.dq.recipe.blocks. This class
* collects those into a commented reference catalog injected into config.dq.yml
* so the user knows what keys are available for homepage: > 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<string, array{label: string, plugin: string}> $blocks
* Flat map of "<recipe>/<key>" => ['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<string, array{label: string, plugin: string}> $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);
}

}
11 changes: 5 additions & 6 deletions templates/config.dq.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<recipe-key>/<block-key>" (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"
Expand Down
Loading
Loading