From a3ea7e5c4e15967c1724b2ab70c7a41d1c49fd58 Mon Sep 17 00:00:00 2001 From: Mark Dumay <61946753+markdumay@users.noreply.github.com> Date: Sun, 12 Jul 2026 06:36:41 +0200 Subject: [PATCH 01/21] docs: add design spec for argument and type system redesign - Inventory of ten confirmed defects and design-debt items in InitArgs.html and InitTypes.html, each mapped to a future test - Agreed decisions: clean core plus compatibility shim inside v5, fully recursive validation, golden-file test harness, cached schema compilation, camelCase-only canonical keys - Architecture: ArgsSchema.html compiler (partialCached), recursive inline validator, Args.html entry point with separated envelope, InitArgs/InitTypes reduced to thin shims - Phased rollout with characterization-first golden tests and a warnings-first strictness transition Co-Authored-By: Claude Fable 5 --- ...-07-12-args-type-system-redesign-design.md | 315 ++++++++++++++++++ 1 file changed, 315 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-12-args-type-system-redesign-design.md diff --git a/docs/superpowers/specs/2026-07-12-args-type-system-redesign-design.md b/docs/superpowers/specs/2026-07-12-args-type-system-redesign-design.md new file mode 100644 index 0000000..88d741b --- /dev/null +++ b/docs/superpowers/specs/2026-07-12-args-type-system-redesign-design.md @@ -0,0 +1,315 @@ +# Design: Argument and Type System Redesign (core + shim) + +- **Date:** 2026-07-12 +- **Status:** Draft — pending user review +- **Scope:** mod-utils v5 (`InitArgs.html`, `InitTypes.html`, and supporting data files) +- **Consumers affected:** ~160 call sites across Hinode, mod-blocks, and 13+ other + modules; docs generation (`args.md` shortcode in Hinode); Bookshop blueprint and + sidecar merging; derived public and private themes. + +## 1. Context and problem statement + +The argument and type system in mod-utils validates arguments passed to shortcodes, +partials, and Bookshop components across the Hinode ecosystem. It also powers +documentation generation and structured logging. The implementation +(`layouts/_partials/utilities/InitArgs.html`, ~270 lines, and +`layouts/_partials/utilities/InitTypes.html`, ~155 lines) has grown through many +incremental refactors — roughly 20 `fix:` commits touch `InitArgs.html` alone — and +none of those fixes shipped with a regression test. The exampleSite is a stub and CI +only verifies that it builds. + +Nested argument types (user-defined types, "UDTs", declared in +`data/structures/_types.yml`) are the weakest area: initialization is bounded to one +level of nesting and validation of nested members effectively does not happen at all. + +### 1.1 Defect and design-debt inventory (current state) + +Confirmed by code inspection; each item becomes a characterization or regression test +in the new harness. + +1. **Nested defaults use the parent's default** — `InitArgs.html:250-253`. When + filling defaults for a missing UDT argument, the loop iterates child definitions + (`$k`, `$v`) but passes the *parent's* `$val.config` / `$val.default` for every + child key. Nested defaults are populated with the wrong values. +2. **Nested validation is zero-deep.** Validation compares only the top-level + reflected Go type (`printf "%T"`). Members inside a `heading`, `items`, + `locations`, etc. are never type-checked, never receive select/range validation, + and unknown nested keys pass silently. Only default-filling attempts one level. +3. **Falsy values skip type validation** — `InitArgs.html:165` + (`if and $val (not (in $expected $actual))`). An argument explicitly set to + `false`, `0`, or `""` is never type-checked. +4. **`or`-based config fallthrough** — inline partial `default.html` + (`InitArgs.html:7-11`) uses `or (index site.Params $config) $default`, so a site + parameter explicitly set to `false` or `0` is ignored in favor of the static + default. This is the exact pitfall Hinode's own documentation warns shortcode + authors about. +5. **Return-envelope namespace collision.** The returned map merges user arguments + with `err`, `errmsg`, `warnmsg`, and `default`. An argument named `default` (or + `err`, …) would silently collide. Currently latent — only the meta-structure + `_args.yml` uses these names — but it constrains the argument namespace forever. +6. **First error wins** — `break` on the first invalid argument hides all subsequent + problems; authors fix errors one build at a time. +7. **No caching.** `InitTypes.html` re-derives the full type map from + `site.Data.structures` on every `InitArgs` call: ~160 call sites × N instances + per page × pages. The derivation is pure per structure name, so it is cacheable. +8. **camelCase duplication.** Every kebab-case key is duplicated in camelCase in the + returned map, roughly doubling map size and creating two sources of truth. +9. **Flat UDT namespace, last-alias-wins** — `InitTypes.html:57` overwrites `$udt` + inside the alias loop; nested UDT names must be globally unique. +10. **Misc:** `findRE` casting applied to non-string values; the float regex accepts + the empty string; the `dict` type aliases to `[]map[string]interface {}` (a + slice of maps), which is surprising; positional-argument matching is O(n²); + the "without recursion" comment on `type-definition.html` is false (it is + recursive). + +### 1.2 What must keep working + +Three consumers share the YAML contract and must stay coherent: + +- **Runtime validation** — `InitArgs` call sites (shortcodes, partials, Bookshop + wrappers) with `structure`, `bookshop`, `child`, `named`, `group` arguments. +- **Documentation generation** — Hinode's `args.md` shortcode reads the same + structure files (global `_arguments.yml` merged with per-structure files and + Bookshop blueprints) to render argument tables. +- **Bookshop / CloudCannon tooling** — component blueprints (`*.bookshop.yml`), + sidecar augmentation files, snake_case/kebab-case key normalization, and the + `_bookshop_name` / `_ordinal` / `id` implicit arguments. + +## 2. Decisions (agreed with maintainer) + +| # | Decision | Choice | +| --- | --- | --- | +| D1 | Compatibility strategy | **B — new clean core inside v5, `InitArgs`/`InitTypes` become thin compatibility shims.** No v6 module-path break; no lockstep migration. | +| D2 | Nesting depth | **Fully recursive** validation and initialization (arbitrary depth, incl. UDT→UDT such as `locations → instructions`). | +| D3 | Test approach | **Golden-file harness** in mod-utils' exampleSite, characterization-first. | +| D4 | Performance | **In scope** — cache compiled schemas with `partialCached`. | +| D5 | Canonical keys in clean API | **camelCase only** in the `Args.html` envelope; the shim keeps today's duplicated spellings. | +| D6 | Unknown nested keys | **Error in strict mode** (consistent with top-level), warnings-first during transition. | +| D7 | Strictness rollout | Newly detectable error classes ship as **warnings for one release cycle** via the shim, then are promoted to errors. | + +### 2.1 Non-goals + +- No change to the YAML schema of `_arguments.yml`, `_types.yml`, structure files, + Bookshop blueprints, or sidecars. +- No migration of call sites in Hinode or other modules (separate follow-up effort). +- No change to `args.md` docs generation (follow-up: consume the compiled schema). +- No mod-utils v6 / module-path change. + +## 3. Architecture + +Two concerns, currently interleaved, are separated: + +1. **What is the contract?** → schema compilation (pure function of site data, + cacheable). +2. **Does this value meet it?** → validation (recursive walk of value against + schema). + +```text +YAML sources per call +┌──────────────────┐ partialCached ┌─────────────────────────┐ +│ _arguments.yml │ ┌────────────► │ utilities/Args.html │ +│ _types.yml │ │ │ - positional mapping │ +│ .yml ├──┤ utilities/ │ - inline/validate.html │ +│ bookshop bluep. │ │ ArgsSchema │ (recursive walker) │ +│ sidecar files │ │ .html │ - envelope assembly │ +│ child structures │ └───────────── └───────────┬─────────────┘ +└──────────────────┘ │ clean envelope + ┌────────────▼─────────────┐ + │ utilities/InitArgs.html │ + │ (compat shim: flatten, │ + │ legacy keys, downgrade │ + │ new errors to warnings) │ + └──────────────────────────┘ +``` + +### 3.1 `utilities/ArgsSchema.html` — schema compiler + +- **Invocation:** `partialCached … (dict "structure" S "bookshop" B "child" C) S B C` + — cached per `(structure, bookshop, child)` triple. `group` is *not* part of the + key; group filtering happens at validation time. +- **Inputs merged (existing semantics preserved):** global `_arguments.yml` → + `_types.yml` → per-structure file → Bookshop blueprint + implicit args + (`_bookshop_name`, `_ordinal`, `id`) + sidecar augmentation → child structure + arguments (`parent`-flagged, `default` stripped). +- **Output:** `{ schema: , err, errmsg }`. +- **Schema node shape** (self-contained; no lookups needed at validation time): + + ```text + node = { + types: []string # normalized accepted reflect-types + primitive names + kind: "scalar" | "dict" | "list" # structural kind for the walker + default: any # optional + config: string # optional dotted site-param path + options: { values | min | max } # optional + optional: bool + position: int # optional, top level only + group: []string # optional + deprecated: string # optional; with alternative, release + camelKey: string # precomputed canonical access key + children: { name → node } # recursive, for dict/list-of-dict UDTs + } + ``` + +- Type aliases (`path`/`select`/`url` → string, `dict`, `slice`) are resolved during + compilation, once. UDT references recurse with **cycle detection** (a UDT + referencing itself or an ancestor reports a compile error instead of recursing + forever). +- Compile-time problems (missing type definition, unknown UDT reference, cycles) are + reported once per structure with a `schema:` prefix to distinguish them from + caller errors. + +### 3.2 `inline/validate.html` — recursive walker + +- **Invocation (internal):** `(dict "value" V "node" N "path" "heading.title" + "strict" bool "site" …)` → `{ value, errmsg, warnmsg, defaulted }`. +- **Per node, in order:** + 1. **Default application** when value is nil: `config` lookup first using + isset-style traversal (an explicit `false`/`0` site param is honored — fixes + defect 4), then `default`. + 2. **Casting:** string→bool/int/float and scalar→string, guarded so regex-based + casts only run on actual strings (fixes defect 10a). + 3. **Type check** against `node.types` — *always*, including falsy values + (fixes defect 3). + 4. **Options:** select values for strings; min/max for numerics. + 5. **Deprecation warning** when the argument was explicitly provided. + 6. **Recursion:** for `kind: dict`, validate each provided member against + `children`, flag unknown members (error in strict, warning otherwise — D6), + and fill member defaults (each child's *own* default — fixes defect 1). For + `kind: list`, validate every element likewise (fixes defect 2). +- All problems are collected; the walker never breaks early (fixes defect 6). + Messages carry the full path: `[card] argument 'heading.title': expected type + 'string', got 'int' with value '5'`. + +### 3.3 `utilities/Args.html` — clean entry point + +- **Signature:** + + ```text + partial "utilities/Args.html" (dict + "structure" S | "bookshop" B # exactly one required + "child" C # optional + "args" .Params # map (named) or slice (positional) + "named" true|false # default true + "group" "shortcode"|"partial"|… # optional required-arg filter + "strict" true|false # default true + ) + ``` + +- **Returns a separated envelope** — user values never share a namespace with + bookkeeping (fixes defect 5): + + ```text + { args: { → value }, # camelCase-only canonical keys (D5) + err: bool, errmsg: []string, warnmsg: []string, defaulted: []string } + ``` + +- Responsibilities: resolve schema via `ArgsSchema` (cached); map positional args + via precomputed positions; normalize snake_case/kebab-case input keys for + Bookshop; handle the `_default` pass-through key; walk each argument with the + validator; enforce required arguments per `group`; assemble the envelope. +- `defaulted` lists dotted paths (`heading.align`) of every value that came from a + default — superset of today's `default` slice. + +### 3.4 Compatibility shims + +- **`utilities/InitArgs.html`** becomes ~40 lines: call `Args.html` with + `strict=false`; flatten `args` into the top-level map; add legacy key spellings + (original kebab/snake keys alongside camelCase, exactly as today); merge + `err`/`errmsg`/`warnmsg`/`default` keys. In non-strict mode the walker classifies + *newly detectable* problems (falsy-value type mismatches, nested type mismatches, + unknown nested keys) as warnings (D7); previously detected error classes remain + errors. Net effect for all ~160 call sites: identical behavior except the + nested-defaults bug fix and new warnings surfacing latent issues. +- **`utilities/InitTypes.html`** delegates to `ArgsSchema` and re-shapes its output + to the legacy `{types, udt, err, errmsg, warnmsg}` form for the few direct + callers. Marked deprecated in docs. + +### 3.5 Error-handling philosophy (unchanged) + +The system never calls `errorf` itself. It returns errors/warnings in the envelope +and callers decide via `LogErr.html` / `LogWarn.html`. This is what makes invalid +inputs testable with golden files. + +## 4. Test harness (phase 0, before any behavior change) + +- **Location:** `mod-utils/exampleSite`, replacing the stub. +- **Case definitions:** `exampleSite/data/tests/.yml` — declarative cases: + + ```yaml + cases: + - name: nested-default-child # BUG: currently returns parent default + structure: test-heading + args: + title: Hello + ``` + + Groups: `defaults`, `casting`, `options`, `positional`, `required`, `nesting`, + `bookshop`, `child`, `errors`, `warnings`, `envelope`. +- **Test structures:** dedicated fixtures under `exampleSite/data/structures/` + (`test-*.yml` plus supporting `_types` entries) so tests do not depend on + production structure files. +- **Rendering:** a test layout iterates every case, invokes the partial under test, + and emits the full result envelope as canonical JSON (`jsonify` sorts map keys) + to `public/tests//index.json` via a JSON output format. +- **Assertion:** `npm test` builds the exampleSite and diffs `public/tests/**` + against committed `tests/golden/*.json`. Any drift fails CI (existing reusable + test workflow, no new infrastructure). +- **Characterization-first:** the initial goldens pin *current* behavior, including + bugs, each annotated `# BUG(): …` referencing §1.1. Every later fix flips its + golden in the same commit — behavior changes stay explicit and reviewable. +- Both `InitArgs.html` (shim path) and `Args.html` (strict path) are exercised: the + same case files run through both entry points where applicable. + +## 5. Phasing + +| Phase | Deliverable | Risk gate | +| --- | --- | --- | +| 0 | Golden harness + characterization suite for current `InitArgs`/`InitTypes` | Goldens reviewed; CI green | +| 1 | `ArgsSchema.html` compiler (cached) + compile-error tests | Schema snapshots for representative structures match expectations | +| 2 | `inline/validate.html` + `utilities/Args.html` + strict-mode goldens | New-API suite green; old suite untouched | +| 3 | Rewire `InitArgs`/`InitTypes` as shims; flip goldens for intentional fixes (defects 1–6); warnings-first strictness | Hinode + mod-blocks exampleSites build without new errors; diff of emitted warnings reviewed | +| 4 | Caching enabled and measured (`hugo --templateMetrics` before/after on Hinode exampleSite) | No behavior drift in goldens; measurable build-time improvement | +| 5 (follow-up, separate effort) | Migrate Hinode v2 + first-party modules to `Args.html`; docs `args.md` consumes `ArgsSchema`; promote warnings to errors after one release cycle | Per-module PRs | + +Phases 0–4 ship as mod-utils v5 minor releases (with clear release notes for the +new warnings). The phase-5 warning→error promotion is behaviorally breaking in the +strict semver sense, but a Go-module major bump would force a `/v6` import path — +the ecosystem-fork problem that ruled out scenario C. It therefore ships as a +well-communicated v5 minor after at least one full release cycle of warnings, +consistent with how mod-utils has rolled out stricter validation historically. + +## 6. Risks and mitigations + +- **Behavioral drift the goldens don't cover.** Mitigation: characterization suite + is written *from the defect inventory and the full feature surface* (every branch + of the current code gets at least one case) before refactoring starts; Hinode and + mod-blocks exampleSites act as integration smoke tests in phase 3. +- **`partialCached` variant explosion.** Cache key is the `(structure, bookshop, + child)` triple; the ecosystem has ~100s of structures, well within Hugo's cache + comfort zone. +- **Hidden reliance on quirks** (e.g. sites depending on falsy values skipping + validation). Mitigation: warnings-first rollout (D7) gives one release cycle of + visibility before promotion. +- **Hugo version differences.** mod-utils supports the Hugo versions Hinode v2 + supports; the harness runs on the pinned CI Hugo version, and no new template + functions beyond that baseline are introduced. +- **Private/derived themes** cannot be inventoried. Mitigation: no API or schema + removal in v5; shims preserved indefinitely within v5. + +## 7. Acceptance criteria + +1. Golden suite covers every §1.1 defect plus the full documented feature surface + (defaults, config lookups, casting, options, positional args, required/groups, + deprecation, `_default`, Bookshop blueprint/sidecar, child structures, + camelization, envelope shape). +2. Defects 1–6 are fixed on the strict path and warn on the shim path; each fix is + visible as a golden diff in its own commit. +3. Recursive validation proven by cases nesting ≥3 levels (UDT → UDT → scalar) and + lists of dicts, including defaults, options, and unknown-key detection at every + level. +4. `ArgsSchema` results are cached; template metrics on the Hinode exampleSite show + reduced cumulative time for argument initialization. +5. Hinode, mod-blocks, and mod-utils exampleSites build with zero new *errors* + through the shim path. +6. No changes required to any YAML structure file, blueprint, or call site outside + mod-utils. From 3f8dc6e5adc66784715a7f5910916de23b8dcf87 Mon Sep 17 00:00:00 2001 From: Mark Dumay <61946753+markdumay@users.noreply.github.com> Date: Sun, 12 Jul 2026 06:48:43 +0200 Subject: [PATCH 02/21] docs: add CloudCannon and Bookshop compatibility constraints to spec - Document the live-editing sandbox expose-glob placement rule for new partials and the null-vs-explicit value distinction required by CloudCannon-authored content - Add CloudCannon-shaped golden cases and child-structure coverage to the test harness section - Correct the phase-3 gate: the mod-blocks exampleSite is an empty placeholder and provides no integration signal until seeded Co-Authored-By: Claude Fable 5 --- ...-07-12-args-type-system-redesign-design.md | 55 +++++++++++++++++-- 1 file changed, 51 insertions(+), 4 deletions(-) diff --git a/docs/superpowers/specs/2026-07-12-args-type-system-redesign-design.md b/docs/superpowers/specs/2026-07-12-args-type-system-redesign-design.md index 88d741b..769067f 100644 --- a/docs/superpowers/specs/2026-07-12-args-type-system-redesign-design.md +++ b/docs/superpowers/specs/2026-07-12-args-type-system-redesign-design.md @@ -73,7 +73,36 @@ Three consumers share the YAML contract and must stay coherent: Bookshop blueprints) to render argument tables. - **Bookshop / CloudCannon tooling** — component blueprints (`*.bookshop.yml`), sidecar augmentation files, snake_case/kebab-case key normalization, and the - `_bookshop_name` / `_ordinal` / `id` implicit arguments. + `_bookshop_name` / `_ordinal` / `id` implicit arguments. See §1.3 for the + specific compatibility constraints. + +### 1.3 CloudCannon / Bookshop compatibility constraints + +Traced through `setup-cloudcannon-cms`, mod-blocks, and mod-bookshop(-hugo): + +1. **Blueprint is the editor contract.** CloudCannon's visual editor derives each + component's shape from `.bookshop.yml`: `spec.structures: [content_blocks]` + registers the component in the structure picker; `blueprint` (snake_case keys, + nested defaults) defines the frontmatter the editor writes. `ArgsSchema` must + keep deriving Bookshop schemas from blueprint + sidecar exactly as `InitTypes` + does today (no YAML change — already a non-goal). +2. **The argument system executes inside CloudCannon's live-editing sandbox.** + The `expose` command of setup-cloudcannon-cms compiles glob lists (e.g. + `_vendor/github.com/gethinode//data/structures/*.yml`, + `…/layouts/**/*.html`) into `bookshop.config.cjs`, which `@bookshop/generate` + uses to make those files available to the browser-side live renderer. Design + rule: **all new partials live in `layouts/_partials/utilities/` (same directory + as `InitArgs.html`) and no new data directories are introduced**, so existing + site expose configurations keep matching without changes. +3. **CloudCannon-authored content is null-heavy.** The editor writes every + blueprint key, typically null for untouched fields, plus `_bookshop_name` and + `_ordinal`. The validator must treat null as "not provided" (eligible for + defaults, exempt from type errors) at **every** nesting level. This is distinct + from explicitly provided `false`/`0`/`""`, which the strictness fix (defect 3) + makes subject to validation. Golden cases must pin both behaviors separately. +4. **`child` structures are load-bearing** — mod-bookshop(-hugo) partials + (`stack.html`, `card-group.html`) call `InitArgs` with `"child" "card"`; this + path gets first-class coverage in the harness. ## 2. Decisions (agreed with maintainer) @@ -245,6 +274,11 @@ inputs testable with golden files. Groups: `defaults`, `casting`, `options`, `positional`, `required`, `nesting`, `bookshop`, `child`, `errors`, `warnings`, `envelope`. +- **CloudCannon-shaped cases:** the `bookshop` group includes payloads as the + CloudCannon editor writes them — every blueprint key present with null values, + snake_case keys, `_bookshop_name`/`_ordinal` included — plus contrasting cases + with explicit `false`/`0`/`""` values, pinning the null-vs-explicit distinction + of §1.3(3) at top level and nested levels. - **Test structures:** dedicated fixtures under `exampleSite/data/structures/` (`test-*.yml` plus supporting `_types` entries) so tests do not depend on production structure files. @@ -267,7 +301,7 @@ inputs testable with golden files. | 0 | Golden harness + characterization suite for current `InitArgs`/`InitTypes` | Goldens reviewed; CI green | | 1 | `ArgsSchema.html` compiler (cached) + compile-error tests | Schema snapshots for representative structures match expectations | | 2 | `inline/validate.html` + `utilities/Args.html` + strict-mode goldens | New-API suite green; old suite untouched | -| 3 | Rewire `InitArgs`/`InitTypes` as shims; flip goldens for intentional fixes (defects 1–6); warnings-first strictness | Hinode + mod-blocks exampleSites build without new errors; diff of emitted warnings reviewed | +| 3 | Rewire `InitArgs`/`InitTypes` as shims; flip goldens for intentional fixes (defects 1–6); warnings-first strictness | Hinode exampleSite builds without new errors; diff of emitted warnings reviewed. (mod-blocks' exampleSite is a single placeholder page with zero components — no signal; CloudCannon-shaped coverage comes from the harness's bookshop fixtures instead. Follow-up: seed mod-blocks' exampleSite with representative block content.) | | 4 | Caching enabled and measured (`hugo --templateMetrics` before/after on Hinode exampleSite) | No behavior drift in goldens; measurable build-time improvement | | 5 (follow-up, separate effort) | Migrate Hinode v2 + first-party modules to `Args.html`; docs `args.md` consumes `ArgsSchema`; promote warnings to errors after one release cycle | Per-module PRs | @@ -282,8 +316,17 @@ consistent with how mod-utils has rolled out stricter validation historically. - **Behavioral drift the goldens don't cover.** Mitigation: characterization suite is written *from the defect inventory and the full feature surface* (every branch - of the current code gets at least one case) before refactoring starts; Hinode and - mod-blocks exampleSites act as integration smoke tests in phase 3. + of the current code gets at least one case) before refactoring starts; the Hinode + exampleSite acts as the integration smoke test in phase 3 (mod-blocks' is an + empty placeholder and provides no signal until seeded — see phase 3 note). +- **CloudCannon live-editing sandbox misses new files.** The live renderer only + sees files matched by each site's expose globs. Mitigation: design rule §1.3(2) + — new partials stay in `layouts/_partials/utilities/`, no new data directories — + so existing configurations keep matching. Verified manually on a + CloudCannon-connected site before the phase-3 release. +- **CloudCannon null-heavy payloads hit the new strictness.** Mitigation: null is + "not provided" at every level by design (§1.3(3)); dedicated golden cases pin it, + and the warnings-first rollout would surface any miss before promotion. - **`partialCached` variant explosion.** Cache key is the `(structure, bookshop, child)` triple; the ecosystem has ~100s of structures, well within Hugo's cache comfort zone. @@ -313,3 +356,7 @@ consistent with how mod-utils has rolled out stricter validation historically. through the shim path. 6. No changes required to any YAML structure file, blueprint, or call site outside mod-utils. +7. CloudCannon compatibility: bookshop golden cases cover editor-shaped payloads + (all-null blueprints, snake_case, implicit args) and the `child` structure path; + all new files respect the expose-glob placement rule (§1.3); live editing + verified manually on a CloudCannon-connected site before the phase-3 release. From acc53fae87aac26a9acbf7f98773434bd1430ec4 Mon Sep 17 00:00:00 2001 From: Mark Dumay <61946753+markdumay@users.noreply.github.com> Date: Sun, 12 Jul 2026 07:15:23 +0200 Subject: [PATCH 03/21] docs: add implementation plan for the argument and type system redesign - Eleven bite-sized tasks: golden harness, five characterization groups, ArgsSchema compiler, Args entry point with recursive validator, InitArgs/InitTypes shims, schema caching, docs - Load-bearing mechanics verified live before planning: JSON output template resolution, the dropped data mount in the exampleSite, and an end-to-end InitArgs call demonstrating the nested-default defect - Every intentional behavior change is gated by an enumerated golden-diff review category Co-Authored-By: Claude Fable 5 --- .../2026-07-12-args-type-system-redesign.md | 2081 +++++++++++++++++ 1 file changed, 2081 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-12-args-type-system-redesign.md diff --git a/docs/superpowers/plans/2026-07-12-args-type-system-redesign.md b/docs/superpowers/plans/2026-07-12-args-type-system-redesign.md new file mode 100644 index 0000000..4c848e4 --- /dev/null +++ b/docs/superpowers/plans/2026-07-12-args-type-system-redesign.md @@ -0,0 +1,2081 @@ +# Argument and Type System Redesign Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the internals of mod-utils' argument validation (`InitArgs.html`/`InitTypes.html`) with a cached schema compiler plus a recursive validator behind a clean `Args.html` API, pinned by a golden-file test harness — while every existing call site keeps working through compatibility shims. + +**Architecture:** Two new partials separate concerns: `ArgsSchema.html` compiles the YAML contract (global `_arguments.yml`, `_types.yml`, structure files, Bookshop blueprints + sidecars, child structures) into a self-contained recursive schema tree, invoked via `partialCached`; `Args.html` walks values against that tree with a recursive inline validator and returns a separated envelope `{args, err, errmsg, warnmsg, defaulted}`. `InitArgs.html` and `InitTypes.html` become thin shims. A golden-file harness in the exampleSite characterizes current behavior *first*; every intentional behavior change lands as a reviewable golden diff. + +**Tech Stack:** Hugo templates (min 0.146, CI pins hugo-extended 0.164 via pnpm), YAML data files, one dependency-free Node script for golden comparison. + +**Spec:** `docs/superpowers/specs/2026-07-12-args-type-system-redesign-design.md` (read it before starting; §1.1 numbers the defects referenced as BUG(n) below). + +## Global Constraints + +- Repository: `/Users/mark/Development/GitHub/gethinode/mod-utils`. Work on a feature branch off `main` (e.g. `feat/args-system-redesign`); never commit to `main`. +- No changes to the YAML schema of `_arguments.yml`, `_types.yml`, structure files, blueprints, or sidecars (spec §2.1). Test fixtures ADD files; they never modify shipped data except where a task says so. +- No new npm dependencies. The golden script is plain Node (`.mjs`, no imports beyond `node:` builtins). +- All new partials live in `layouts/_partials/utilities/` — CloudCannon expose-glob placement rule (spec §1.3.2). No new data directories in the module itself. +- Hugo min version 0.146 (see `config.toml`); use no template functions newer than that. +- Commit messages: Angular Conventional Commits, body lines ≤ 100 chars, ending with `Co-Authored-By: Claude Fable 5 `. +- The pre-commit hook runs `pnpm build` (full exampleSite build). Commits fail if the site doesn't build. +- Run all commands from the repo root. `pnpm exec hugo` is the pinned Hugo binary. +- **Golden discipline:** when a golden file changes, the step MUST say why. An unexplained golden diff is a bug in your change — stop and investigate, never blindly update. +- Null semantics (spec §1.3.3): `null`/absent = "not provided" (eligible for defaults, exempt from type errors) at every nesting level. Explicit `false`/`0`/`""` are provided values and get validated. Never conflate these. + +--- + +### Task 1: Golden-file harness skeleton + +**Files:** +- Modify: `exampleSite/hugo.toml` (add data mount — verified necessary: the explicit `module.imports.mounts` drop the module's own `data` mount, so `site.Data.structures` is currently empty in exampleSite builds) +- Create: `exampleSite/layouts/tests/single.json` (test runner layout; old-style template naming verified to resolve on Hugo 0.164) +- Create: `exampleSite/content/tests/envelope.md` +- Create: `exampleSite/data/tests/envelope.yml` +- Create: `exampleSite/data/structures/test-envelope.yml` +- Create: `tests/golden.mjs` +- Modify: `package.json` (scripts) +- Test: the harness itself — `pnpm test` fails on drift, passes on match + +**Interfaces:** +- Produces: `pnpm test` (build exampleSite + compare `exampleSite/public/tests//index.json` against `tests/golden/.json`), `pnpm test:update` (regenerate goldens). Case-file format consumed by all later tasks: + +```yaml +# exampleSite/data/tests/.yml +cases: + - name: unique-case-name # key in the JSON output + structure: test-something # OR bookshop: test-hero + child: test-card # optional + group: shortcode # optional + named: true # optional, default true; false = args is a positional slice + args: {} # map (named) or list (positional) +``` + +- [ ] **Step 1: Add the data mount to `exampleSite/hugo.toml`** + +Append to the existing `[[module.imports.mounts]]` list (inside the same `[[module.imports]]` block): + +```toml + [[module.imports.mounts]] + source = "data" + target = "data" +``` + +- [ ] **Step 2: Write the test-runner layout** + +Create `exampleSite/layouts/tests/single.json`: + +```go-html-template +{{- /* + Test runner: renders the InitArgs result envelope for every case in the group's case file + (data/tests/.yml) or, when the page defines cases in frontmatter, from .Params.cases. + Output is canonical JSON (jsonify sorts map keys) compared against tests/golden/.json. +*/ -}} +{{- $group := .File.ContentBaseName -}} +{{- $data := index site.Data.tests $group | default .Params -}} +{{- $results := dict -}} +{{- range $case := $data.cases -}} + {{- $params := dict + "structure" ($case.structure | default "") + "args" $case.args + "named" (ne $case.named false) + -}} + {{- with $case.bookshop }}{{ $params = merge $params (dict "bookshop" . "structure" "") }}{{ end -}} + {{- with $case.child }}{{ $params = merge $params (dict "child" .) }}{{ end -}} + {{- with $case.group }}{{ $params = merge $params (dict "group" .) }}{{ end -}} + {{- $legacy := partial "utilities/InitArgs.html" $params -}} + {{- $results = merge $results (dict $case.name (dict "initargs" $legacy)) -}} +{{- end -}} +{{- jsonify (dict "indent" " ") $results -}} +``` + +- [ ] **Step 3: Write the first fixture and case group (envelope characterization, BUG(5))** + +Create `exampleSite/data/structures/test-envelope.yml`: + +```yaml +comment: >- + Test fixture demonstrating the envelope namespace collision: an argument named + 'default' shares the return map with the bookkeeping 'default' slice. +arguments: + default: + type: string + optional: true + plain: + type: string + optional: true +``` + +Create `exampleSite/data/tests/envelope.yml`: + +```yaml +cases: + - name: plain-arg + structure: test-envelope + args: + plain: hello + # BUG(5): the user's value for 'default' is clobbered by the bookkeeping merge + - name: default-arg-collision + structure: test-envelope + args: + default: user-value +``` + +Create `exampleSite/content/tests/envelope.md`: + +```markdown +--- +title: Envelope +outputs: ["json"] +--- +``` + +- [ ] **Step 4: Write the golden comparison script** + +Create `tests/golden.mjs`: + +```js +#!/usr/bin/env node +// Compares generated test output (exampleSite/public/tests//index.json) against the +// committed golden files (tests/golden/.json). Run with --update to (re)write goldens. +import {existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync} from 'node:fs'; +import path from 'node:path'; +import process from 'node:process'; + +const generatedRoot = path.join('exampleSite', 'public', 'tests'); +const goldenRoot = path.join('tests', 'golden'); +const update = process.argv.includes('--update'); + +const groups = readdirSync(generatedRoot, {withFileTypes: true}) + .filter((entry) => entry.isDirectory() + && existsSync(path.join(generatedRoot, entry.name, 'index.json'))) + .map((entry) => entry.name); + +if (groups.length === 0) { + console.error(`no generated test output found under ${generatedRoot}`); + process.exit(1); +} + +let failed = false; +const seen = new Set(); +for (const group of groups) { + const generated = readFileSync(path.join(generatedRoot, group, 'index.json'), 'utf8'); + const goldenFile = path.join(goldenRoot, `${group}.json`); + seen.add(`${group}.json`); + if (update) { + mkdirSync(goldenRoot, {recursive: true}); + writeFileSync(goldenFile, generated); + console.log(`updated ${goldenFile}`); + continue; + } + + if (!existsSync(goldenFile)) { + console.error(`MISSING golden: ${goldenFile} (run: pnpm test:update)`); + failed = true; + continue; + } + + const golden = readFileSync(goldenFile, 'utf8'); + if (golden !== generated) { + failed = true; + console.error(`DIFF in group '${group}' (golden vs generated):`); + const a = golden.split('\n'); + const b = generated.split('\n'); + for (let i = 0; i < Math.max(a.length, b.length); i++) { + if (a[i] !== b[i]) { + console.error(` line ${i + 1}:\n - ${a[i] ?? ''}\n + ${b[i] ?? ''}`); + } + } + } +} + +if (!update && existsSync(goldenRoot)) { + for (const file of readdirSync(goldenRoot)) { + if (!seen.has(file)) { + console.error(`ORPHAN golden without generated output: ${file}`); + failed = true; + } + } +} + +if (failed) process.exit(1); +console.log(`golden check passed (${groups.length} groups)`); +``` + +- [ ] **Step 5: Add npm scripts** + +In `package.json`, replace `"test": "pnpm build"` with: + +```json +"pretest": "pnpm clean && pnpm mod:vendor", +"test": "hugo -s exampleSite && node tests/golden.mjs", +"test:update": "pnpm clean && pnpm mod:vendor && hugo -s exampleSite && node tests/golden.mjs --update" +``` + +Note: `test` deliberately builds WITHOUT `--minify` so the JSON output keeps its `jsonify` indentation for reviewable golden diffs. + +- [ ] **Step 6: Run, verify failure mode, generate goldens, verify pass** + +```bash +pnpm test # Expected: exit 1, "MISSING golden: tests/golden/envelope.json" +pnpm test:update # Expected: "updated tests/golden/envelope.json" +pnpm test # Expected: "golden check passed (1 groups)" +``` + +- [ ] **Step 7: Inspect the golden and confirm BUG(5) is characterized** + +Open `tests/golden/envelope.json`. Expected shape: `default-arg-collision.initargs.default` is `[]` (the bookkeeping slice), NOT `"user-value"` — the user's value was clobbered. Add nothing; the golden IS the characterization. If instead you see `"user-value"`, the collision behaves differently than the spec assumed — record what you see; the golden pins reality. + +- [ ] **Step 8: Commit** + +```bash +git add exampleSite package.json tests +git commit -m "test: add golden-file harness with envelope characterization" +``` + +--- + +### Task 2: Characterization — defaults, casting, and options groups + +**Files:** +- Create: `exampleSite/data/structures/test-default.yml`, `test-cast.yml`, `test-options.yml` +- Create: `exampleSite/data/tests/defaults.yml`, `casting.yml`, `options.yml` +- Create: `exampleSite/content/tests/defaults.md`, `casting.md`, `options.md` +- Modify: `exampleSite/hugo.toml` (add `[params]` for config-lookup cases) + +**Interfaces:** +- Consumes: harness from Task 1. +- Produces: goldens `tests/golden/{defaults,casting,options}.json` pinning BUG(3) and BUG(4). + +- [ ] **Step 1: Add site params used by config-lookup cases to `exampleSite/hugo.toml`** + +```toml +[params.test] + enabled = true + disabled = false + label = "from-site-params" +``` + +- [ ] **Step 2: Fixtures** + +Create `exampleSite/data/structures/test-default.yml`: + +```yaml +comment: Test fixture for static, config-based, and deprecated defaults. +arguments: + static-label: + type: string + optional: true + default: fallback + from-config: + type: string + optional: true + config: test.label + default: static-fallback + from-config-true: + type: bool + optional: true + config: test.enabled + default: false + from-config-false: + type: bool + optional: true + config: test.disabled + default: true + from-config-missing: + type: string + optional: true + config: test.does-not-exist + default: static-fallback + legacy-arg: + type: string + optional: true + deprecated: 1.2.0 + alternative: static-label +``` + +Create `exampleSite/data/structures/test-cast.yml`: + +```yaml +comment: Test fixture for type casting, scalar validation, and generic collection types. +arguments: + flag: + type: bool + optional: true + count: + type: int + optional: true + ratio-value: + type: float + optional: true + label: + type: string + optional: true + data: + type: dict + optional: true + items-list: + type: slice + optional: true +``` + +Create `exampleSite/data/structures/test-options.yml`: + +```yaml +comment: Test fixture for permitted values and numeric ranges. +arguments: + mode: + type: select + optional: true + default: auto + options: + values: [auto, manual] + size: + type: int + optional: true + options: + min: 1 + max: 10 +``` + +- [ ] **Step 3: Case files** + +Create `exampleSite/data/tests/defaults.yml`: + +```yaml +cases: + - name: static-default-applied + structure: test-default + args: {} + - name: static-default-overridden + structure: test-default + args: {static-label: custom} + - name: config-present-wins + structure: test-default + args: {} + # BUG(4): site param explicitly false falls through to the static default (or-based lookup) + - name: config-false-fallthrough + structure: test-default + args: {} + - name: deprecated-warns + structure: test-default + args: {legacy-arg: old} +``` + +(Note: `config-*` cases exercise different arguments of the same structure in one call each; keep them +as separate named cases anyway so each behavior has its own addressable golden entry — the runner +result includes all arguments per case, and the case name documents which argument is under test.) + +Create `exampleSite/data/tests/casting.yml`: + +```yaml +cases: + - name: string-to-bool + structure: test-cast + args: {flag: "true"} + - name: string-to-int + structure: test-cast + args: {count: "42"} + - name: string-to-float + structure: test-cast + args: {ratio-value: "1.5"} + - name: int-to-string + structure: test-cast + args: {label: 7} + - name: bool-arg-real-bool + structure: test-cast + args: {flag: true} + # BUG(3): explicit false skips type validation entirely — a bool false into an int arg passes + - name: falsy-wrong-type-skips-validation + structure: test-cast + args: {count: false} + - name: wrong-type-errors + structure: test-cast + args: {count: [1, 2]} + # Latent quirk: type 'float' expects reflect name 'float', but real floats reflect as 'float64' + - name: real-float-value + structure: test-cast + args: {ratio-value: 1.5} + # Latent quirk: the legacy float regex matches the empty string + - name: empty-string-float + structure: test-cast + args: {ratio-value: ""} + - name: generic-dict-as-map + structure: test-cast + args: + data: {anything: goes} + # Legacy quirk kept on purpose: type 'dict' also accepts a slice of maps + - name: generic-dict-as-maplist + structure: test-cast + args: + data: + - anything: goes + - name: generic-slice + structure: test-cast + args: + items-list: [1, "two", true] +``` + +Create `exampleSite/data/tests/options.yml`: + +```yaml +cases: + - name: select-valid + structure: test-options + args: {mode: manual} + - name: select-invalid + structure: test-options + args: {mode: bogus} + - name: select-default + structure: test-options + args: {} + - name: range-inside + structure: test-options + args: {size: 5} + - name: range-below-min + structure: test-options + args: {size: 0} + - name: range-above-max + structure: test-options + args: {size: 11} +``` + +Create the three content pages (same frontmatter as Task 1 Step 3, adjusting `title`): +`exampleSite/content/tests/defaults.md`, `casting.md`, `options.md`, each: + +```markdown +--- +title: +outputs: ["json"] +--- +``` + +- [ ] **Step 4: Generate, inspect, commit** + +```bash +pnpm test:update && pnpm test # Expected: "golden check passed (4 groups)" +``` + +Inspect each new golden. Expected characterizations to verify and note inline (as YAML comments in the case files if reality differs from the BUG annotations): `config-false-fallthrough` shows `fromConfigFalse: true` (the static default — BUG(4)); `falsy-wrong-type-skips-validation` shows `err: false` (BUG(3)); `real-float-value` — record whatever happens (this pins the float/float64 quirk, whichever way it goes). Then: + +```bash +git add exampleSite tests/golden +git commit -m "test: characterize defaults, casting, and options behavior" +``` + +--- + +### Task 3: Characterization — positional, required, and group-filtered arguments + +**Files:** +- Create: `exampleSite/data/structures/test-required.yml` +- Create: `exampleSite/data/tests/positional.yml`, `required.yml` +- Create: `exampleSite/content/tests/positional.md`, `required.md` + +**Interfaces:** consumes Task 1 harness; produces goldens pinning BUG(6) (first-error-wins) and positional/required/group semantics. + +- [ ] **Step 1: Fixture** + +Create `exampleSite/data/structures/test-required.yml`: + +```yaml +comment: Test fixture for required, positional, and group-scoped arguments. +arguments: + name: + type: string + optional: false + position: 0 + kind: + type: string + optional: true + position: 1 + partial-only: + type: string + optional: false + group: partial + flag: + type: bool + optional: true +``` + +- [ ] **Step 2: Case files** + +Create `exampleSite/data/tests/positional.yml`: + +```yaml +cases: + - name: both-positions + structure: test-required + named: false + group: shortcode + args: ["alpha", "beta"] + - name: first-position-only + structure: test-required + named: false + group: shortcode + args: ["alpha"] + - name: excess-position-errors + structure: test-required + named: false + group: shortcode + args: ["alpha", "beta", "gamma"] +``` + +Create `exampleSite/data/tests/required.yml`: + +```yaml +cases: + - name: required-provided + structure: test-required + group: shortcode + args: {name: alpha} + - name: required-missing-errors + structure: test-required + group: shortcode + args: {flag: true} + - name: group-filter-skips-partial-arg + structure: test-required + group: shortcode + args: {name: alpha} + - name: group-partial-requires-both + structure: test-required + group: partial + args: {name: alpha} + - name: no-group-requires-all + structure: test-required + args: {name: alpha} + # BUG(6): two problems, only the first reported (break on first error) + - name: first-error-wins + structure: test-required + group: shortcode + args: {bogus-one: 1, bogus-two: 2} +``` + +Create `exampleSite/content/tests/positional.md` and `required.md` (frontmatter pattern from Task 1 Step 3). + +- [ ] **Step 3: Generate, inspect, commit** + +```bash +pnpm test:update && pnpm test # Expected: "golden check passed (6 groups)" +``` + +Verify `first-error-wins` reports exactly one `errmsg` entry (BUG(6)), and `group-partial-requires-both` errors on the missing `partial-only`. + +```bash +git add exampleSite tests/golden +git commit -m "test: characterize positional, required, and group-scoped arguments" +``` + +--- + +### Task 4: Characterization — nested types, child structures, and frontmatter-typed values + +**Files:** +- Create: `exampleSite/data/structures/_types.yml` (project-level copy of the module's file plus test types — the project file SHADOWS the module's per Hugo data precedence; header comment must state the sync obligation) +- Create: `exampleSite/data/structures/test-nested.yml`, `test-stack.yml`, `test-card.yml` +- Create: `exampleSite/data/tests/nesting.yml`, `child.yml` +- Create: `exampleSite/content/tests/nesting.md`, `child.md`, `frontmatter.md` + +**Interfaces:** consumes Task 1 harness; produces goldens pinning BUG(1) (nested defaults use parent's default), BUG(2) (zero-deep validation), child-structure merging, and frontmatter-vs-data value typing (`maps.Params` vs `map[string]interface {}`). + +- [ ] **Step 1: Create the shadowing `_types.yml`** + +Copy the module file and append test types: + +```bash +cp data/structures/_types.yml exampleSite/data/structures/_types.yml +``` + +Prepend this header comment to the copy: + +```yaml +# NOTE: this file SHADOWS the module's data/structures/_types.yml for exampleSite builds +# (project data files take precedence per file). Keep the production entries in sync when +# the module file changes, and append test-only types below the marker. +``` + +Append at the end: + +```yaml + # --- test-only types below this marker --- +``` + +(No test types are needed yet — Task 6 appends one below the marker. The marker line itself must +exist so later appends have an anchor and the sync obligation stays visible.) + +- [ ] **Step 2: Fixtures** + +Create `exampleSite/data/structures/test-nested.yml`: + +```yaml +comment: >- + Test fixture for nested user-defined types. 'heading' is a dict UDT with defaulted + members; 'locations' nests a second UDT ('instructions') for depth-3 coverage. +arguments: + heading: + optional: true + locations: + optional: true + title: + type: string + optional: true +``` + +Create `exampleSite/data/structures/test-card.yml` (child structure; `parent`-flagged args cascade into the parent schema): + +```yaml +comment: Test child structure exercising the 'child' parameter of InitArgs. +arguments: + hook: + type: string + optional: true + parent: cascade + default: child-default + ratio: + type: string + optional: true + parent: merge + ignored-non-parent: + type: string + optional: true +``` + +Create `exampleSite/data/structures/test-stack.yml`: + +```yaml +comment: Test parent structure used together with the test-card child structure. +arguments: + title: + type: string + optional: true +``` + +- [ ] **Step 3: Case files** + +Create `exampleSite/data/tests/nesting.yml`: + +```yaml +cases: + # BUG(1): heading omitted → members with defaults get keys but the PARENT's (nil) default + # as value. Verified live: {"align": null, "arrangement": null, "size": null, "width": null} + # even though _arguments.yml gives align default 'start' and width default 8. + - name: absent-udt-shape-fill + structure: test-nested + args: {} + - name: heading-valid + structure: test-nested + args: + heading: + title: Hello + align: center + # BUG(2): nested member type violations pass silently (align expects a select string) + - name: nested-wrong-type-passes + structure: test-nested + args: + heading: + title: Hello + align: 42 + # BUG(2): unknown nested members pass silently + - name: nested-unknown-key-passes + structure: test-nested + args: + heading: + title: Hello + typo-key: oops + - name: depth-three-nesting + structure: test-nested + args: + locations: + - title: Office + instructions: + - title: Parking + description: Use lot B + - name: udt-list-empty + structure: test-nested + args: + locations: [] + # spec §1.3.3: null members are "not provided" — CloudCannon writes these + - name: nested-null-members + structure: test-nested + args: + heading: + title: Hello + preheading: null + align: null +``` + +Create `exampleSite/data/tests/child.yml`: + +```yaml +cases: + - name: child-args-cascade + structure: test-stack + child: test-card + args: {title: Stack, hook: custom-hook} + # child arg defaults are stripped when merged (InitTypes drops 'default' for parent args) + - name: child-default-stripped + structure: test-stack + child: test-card + args: {title: Stack} + - name: child-non-parent-arg-rejected + structure: test-stack + child: test-card + args: {ignored-non-parent: nope} +``` + +Create `exampleSite/content/tests/nesting.md` and `child.md` (frontmatter pattern from Task 1 Step 3). + +- [ ] **Step 4: Frontmatter-typed group** + +Create `exampleSite/content/tests/frontmatter.md` — cases live in FRONTMATTER so values arrive as +Hugo params (`maps.Params`, lowercased keys) exactly as CloudCannon/Bookshop content does, instead +of data-file typing. The runner layout already falls back to `.Params` when no data file exists. + +```markdown +--- +title: Frontmatter +outputs: ["json"] +cases: + - name: params-typed-nested-map + structure: test-nested + args: + heading: + title: Hello + align: center + - name: params-typed-scalars + structure: test-cast + args: + flag: true + count: 42 +--- +``` + +- [ ] **Step 5: Generate, inspect carefully, commit** + +```bash +pnpm test:update && pnpm test # Expected: "golden check passed (9 groups)" +``` + +Critical inspection: compare `params-typed-nested-map` (frontmatter group) against `heading-valid` +(nesting group). If the frontmatter one shows a type error where the data one passes, the `%T` +string-matching has a `maps.Params` blind spot — a new latent defect. Record it as a `# BUG:` comment +on the case and list it in the commit body; the strict core (Task 7+) must handle both map types via +`reflect.IsMap`. + +```bash +git add exampleSite tests/golden +git commit -m "test: characterize nested types, child structures, and frontmatter typing" +``` + +--- + +### Task 5: Characterization — Bookshop components (CloudCannon-shaped payloads) + +**Files:** +- Create: `exampleSite/data/structures/components/test-hero/test-hero.bookshop.yml` +- Create: `exampleSite/data/structures/components/test-hero/test-hero.yml` +- Create: `exampleSite/data/tests/bookshop.yml` +- Create: `exampleSite/content/tests/bookshop.md` + +**Interfaces:** consumes Task 1 harness; produces `tests/golden/bookshop.json` pinning blueprint derivation, sidecar merging, snake/kebab normalization, implicit args, and null-heavy CloudCannon payloads (spec §1.3.3). + +- [ ] **Step 1: Component fixtures** + +Create `exampleSite/data/structures/components/test-hero/test-hero.bookshop.yml`: + +```yaml +spec: + structures: + - content_blocks + label: Test hero + description: Bookshop fixture for schema derivation tests + icon: title + tags: [] +blueprint: + heading: + title: + align: start + link_type: button + show_more: false + width: 8 +``` + +Create the sidecar `exampleSite/data/structures/components/test-hero/test-hero.yml` +(kebab keys on purpose — exercises the snake↔kebab sidecar merge): + +```yaml +comment: Sidecar for the test hero component. +arguments: + heading: + optional: false + link-type: + type: select + optional: true + options: + values: [button, link] + show-more: + type: bool + optional: true + width: + type: int + optional: true +``` + +- [ ] **Step 2: Case file** + +Create `exampleSite/data/tests/bookshop.yml`: + +```yaml +cases: + - name: snake-case-args + bookshop: test-hero + args: + _bookshop_name: test-hero + heading: + title: Hello + link_type: link + show_more: true + - name: kebab-case-warns + bookshop: test-hero + args: + heading: + title: Hello + link-type: link + # CloudCannon-shaped: every blueprint key present, untouched fields null, implicit args set + - name: cloudcannon-null-heavy + bookshop: test-hero + args: + _bookshop_name: test-hero + _ordinal: 0 + heading: + title: Hello + align: null + link_type: null + show_more: null + width: null + # explicit falsy values are NOT null — pin the distinction (spec §1.3.3) + - name: cloudcannon-explicit-false + bookshop: test-hero + args: + _bookshop_name: test-hero + heading: + title: Hello + show_more: false + width: 0 + - name: bookshop-prefix-via-structure + structure: bookshop-test-hero + args: + heading: + title: Hello + - name: unknown-bookshop-arg + bookshop: test-hero + args: + heading: + title: Hello + bogus: nope +``` + +Create `exampleSite/content/tests/bookshop.md` (frontmatter pattern from Task 1 Step 3). + +- [ ] **Step 3: Generate, inspect, commit** + +```bash +pnpm test:update && pnpm test # Expected: "golden check passed (10 groups)" +``` + +Inspect: `cloudcannon-null-heavy` must have `err: false` (nulls tolerated today — this MUST stay +true forever); `kebab-case-warns` shows the prefer-snake_case warning; note in a case comment +whether the blueprint scalar default (`align: start`, `width: 8`) survives the sidecar merge — +current code discards a scalar blueprint default when a sidecar entry merges over it (candidate +defect; the core fixes it in Task 6 and the golden flip will show it). + +```bash +git add exampleSite tests/golden +git commit -m "test: characterize bookshop blueprint, sidecar, and cloudcannon payloads" +``` + +Phase-0 exit gate: 10 golden groups committed, `pnpm test` green, every BUG(n) from spec §1.1 that +is observable through the public API has at least one pinned case. + +--- + +### Task 6: `ArgsSchema.html` — cached schema compiler + +**Files:** +- Create: `layouts/_partials/utilities/ArgsSchema.html` +- Create: `exampleSite/data/structures/test-cycle.yml` +- Modify: `exampleSite/data/structures/_types.yml` (append cycle type) +- Create: `exampleSite/data/tests/schema.yml`, `exampleSite/content/tests/schema.md` +- Modify: `exampleSite/layouts/tests/single.json` (support `api: schema` cases) + +**Interfaces:** +- Consumes: YAML data sources (unchanged). +- Produces — the contract every later task depends on: + +```text +partial "utilities/ArgsSchema.html" (dict "structure" S "bookshop" B "child" C) + → dict: + "schema" map: declared argument name → node + "positions" map: position as string → argument name + "err" bool + "errmsg" []string, each prefixed "schema:" for compile-time problems +node: + "name" string declared argument name + "camelKey" string canonical access key (kebab/snake → camelCase, leading _ kept) + "types" []string declared type names + "accepts" []string value-kind tokens: string|bool|int|float|map|slice|maplist + "kind" string scalar|dict|list + "children" map member name → node (single user-defined type only) + "udtType" string the user-defined type name (only when children present) + "default", "config", "options", "optional", "position", "group" ([]string), + "deprecated", "alternative", "release", "parent", "comment" — present only when defined +``` + +Callers MUST invoke it through `partialCached … (or $structure "") (or $bookshop "") (or $child "")` +(introduced for real in Task 12; direct `partial` is fine until then). + +- [ ] **Step 1: Write the compiler** + +Create `layouts/_partials/utilities/ArgsSchema.html`: + +```go-html-template + + +{{/* + Compile the argument schema of a structure or bookshop component into a self-contained, + recursive schema tree. The result is a pure function of the site's data files; invoke through + partialCached keyed by the (structure, bookshop, child) triple. See the plan/spec for the + node shape. +*/}} + +{{ define "_partials/inline/camel-key.html" }} + {{ $match := findRESubmatch "^(_*)(.*)$" . }} + {{ $prefix := index $match 0 1 }} + {{ $body := replaceRE "_" "-" (index $match 0 2) }} + {{ $result := "" }} + {{ range $index, $word := split $body "-" }} + {{ if gt $index 0 }} + {{ $result = print $result (strings.FirstUpper $word) }} + {{ else }} + {{ $result = strings.ToLower $word }} + {{ end }} + {{ end }} + {{ return print $prefix $result }} +{{ end }} + +{{ define "_partials/inline/compile-node.html" }} + {{ $key := .key }} + {{ $val := .val }} + {{ $arguments := .arguments }} + {{ $typesData := .typesData }} + {{ $stack := .stack }} + {{ $path := .path }} + {{ $errmsg := slice }} + + {{/* normalize the key for the global lookup: snake_case → kebab-case, keep leading underscores */}} + {{ $match := findRESubmatch "^(_*)(.*)$" $key }} + {{ $normKey := print (index $match 0 1) (replaceRE "_" "-" (index $match 0 2)) }} + + {{/* merge the global definition with inline overrides from the structure file or blueprint */}} + {{ $def := index $arguments $normKey | default dict }} + {{ if reflect.IsMap $val }} + {{ $def = merge $def $val }} + {{ else if and (ne $val nil) (not (reflect.IsSlice $val)) }} + {{ $def = merge $def (dict "default" $val) }} + {{ end }} + + {{ if not $def.type }} + {{ $errmsg = $errmsg | append (printf "schema: missing type for '%s'" $path) }} + {{ return (dict "node" nil "errmsg" $errmsg) }} + {{ end }} + + {{ $declared := slice | append $def.type }} + {{ $accepts := slice }} + {{ $kind := "scalar" }} + {{ $children := dict }} + {{ $udtType := "" }} + {{ $udts := 0 }} + + {{ range $type := $declared }} + {{ if in (slice "string" "path" "select" "url") $type }} + {{ $accepts = $accepts | append "string" }} + {{ else if eq $type "bool" }} + {{ $accepts = $accepts | append "bool" }} + {{ else if in (slice "int" "int64") $type }} + {{ $accepts = $accepts | append "int" }} + {{ else if in (slice "float" "float64") $type }} + {{ $accepts = $accepts | append "float" "int" }} + {{ else if eq $type "slice" }} + {{ $kind = "list" }} + {{ $accepts = $accepts | append "slice" "maplist" }} + {{ else if eq $type "dict" }} + {{/* legacy quirk kept on purpose: a generic dict accepts a map or a slice of maps */}} + {{ $kind = "dict" }} + {{ $accepts = $accepts | append "map" "maplist" }} + {{ else }} + {{ $shape := index $typesData $type }} + {{ if eq $shape nil }} + {{ $errmsg = $errmsg | append (printf "schema: unknown type '%s' for '%s'" $type $path) }} + {{ else if in $stack $type }} + {{ $errmsg = $errmsg | append (printf "schema: circular type reference '%s' for '%s'" $type $path) }} + {{ else }} + {{ $udts = add $udts 1 }} + {{ $members := $shape }} + {{ if reflect.IsSlice $shape }} + {{ $kind = "list" }} + {{ $accepts = $accepts | append "slice" "maplist" }} + {{ $members = index $shape 0 | default dict }} + {{ else }} + {{ $kind = "dict" }} + {{ $accepts = $accepts | append "map" }} + {{ end }} + {{ if eq $udts 1 }} + {{ $udtType = $type }} + {{ range $member, $memberVal := $members }} + {{ $child := partial "inline/compile-node.html" (dict + "key" $member + "val" $memberVal + "arguments" $arguments + "typesData" $typesData + "stack" ($stack | append $type) + "path" (printf "%s.%s" $path $member) + ) }} + {{ $errmsg = $errmsg | append $child.errmsg }} + {{ with $child.node }}{{ $children = merge $children (dict $member .) }}{{ end }} + {{ end }} + {{ end }} + {{ end }} + {{ end }} + {{ end }} + + {{/* a union of multiple user-defined types is shape-checked only, not recursed */}} + {{ if gt $udts 1 }}{{ $children = dict }}{{ $udtType = "" }}{{ end }} + + {{ $node := dict + "name" $key + "camelKey" (partial "inline/camel-key.html" $key) + "types" $declared + "accepts" (uniq $accepts) + "kind" $kind + "optional" (default false $def.optional) + }} + {{ if $children }}{{ $node = merge $node (dict "children" $children "udtType" $udtType) }}{{ end }} + {{ if ne $def.default nil }}{{ $node = merge $node (dict "default" $def.default) }}{{ end }} + {{ with $def.config }}{{ $node = merge $node (dict "config" .) }}{{ end }} + {{ with $def.options }}{{ $node = merge $node (dict "options" .) }}{{ end }} + {{ if isset $def "position" }}{{ $node = merge $node (dict "position" $def.position) }}{{ end }} + {{ with $def.group }}{{ $node = merge $node (dict "group" (slice | append .)) }}{{ end }} + {{ with $def.deprecated }}{{ $node = merge $node (dict "deprecated" (string .)) }}{{ end }} + {{ with $def.alternative }}{{ $node = merge $node (dict "alternative" .) }}{{ end }} + {{ with $def.release }}{{ $node = merge $node (dict "release" .) }}{{ end }} + {{ with $def.parent }}{{ $node = merge $node (dict "parent" .) }}{{ end }} + {{ with $def.comment }}{{ $node = merge $node (dict "comment" .) }}{{ end }} + + {{ return (dict "node" $node "errmsg" $errmsg) }} +{{ end }} + +{{/* main body */}} +{{ $structure := .structure }} +{{ $bookshop := .bookshop }} +{{ $child := .child }} +{{ $errmsg := slice }} + +{{ $arguments := ((index site.Data.structures "_arguments") | default dict).arguments | default dict }} +{{ $typesData := ((index site.Data.structures "_types") | default dict).types | default dict }} + +{{/* assemble the raw argument map from the structure file or the bookshop blueprint + sidecar */}} +{{ $raw := dict }} +{{ if $structure }} + {{ $raw = ((index site.Data.structures $structure) | default dict).arguments | default dict }} +{{ else if $bookshop }} + {{ $component := index (site.Data.structures.components | default dict) $bookshop | default dict }} + {{ $raw = (index $component (printf "%s.bookshop" $bookshop) | default dict).blueprint | default dict }} + {{ $raw = merge $raw (dict "_bookshop_name" nil "_ordinal" nil "id" nil) }} + {{ $sidecar := ((index $component $bookshop) | default dict).arguments | default dict }} + {{ range $key, $def := $sidecar }} + {{ if reflect.IsMap $def }} + {{ $snakeKey := replaceRE "-" "_" $key }} + {{ $target := cond (isset $raw $snakeKey) $snakeKey $key }} + {{ $existing := index $raw $target }} + {{ if not (reflect.IsMap $existing) }} + {{/* intentional fix over legacy: a scalar blueprint value is the default — keep it */}} + {{ $existing = cond (eq $existing nil) dict (dict "default" $existing) }} + {{ end }} + {{ $raw = merge $raw (dict $target (merge $existing $def)) }} + {{ end }} + {{ end }} +{{ else }} + {{ $errmsg = $errmsg | append "schema: missing value for param 'structure' or 'bookshop'" }} +{{ end }} + +{{/* merge parent-flagged arguments from the child structure, stripping their defaults */}} +{{ if $child }} + {{ $extra := ((index site.Data.structures $child) | default dict).arguments }} + {{ if not $extra }} + {{ $errmsg = $errmsg | append (printf "schema: missing definitions: %s" $child) }} + {{ else }} + {{ range $key, $val := $extra }} + {{ if and (reflect.IsMap $val) $val.parent }} + {{ $clean := dict }} + {{ range $k, $v := $val }} + {{ if ne $k "default" }}{{ $clean = merge $clean (dict $k $v) }}{{ end }} + {{ end }} + {{ $raw = merge $raw (dict $key $clean) }} + {{ end }} + {{ end }} + {{ end }} +{{ end }} + +{{/* compile each argument into a schema node */}} +{{ $schema := dict }} +{{ $positions := dict }} +{{ $name := or $structure $bookshop }} +{{ range $key, $val := $raw }} + {{ $result := partial "inline/compile-node.html" (dict + "key" $key + "val" $val + "arguments" $arguments + "typesData" $typesData + "stack" slice + "path" (printf "%s.%s" $name $key) + ) }} + {{ $errmsg = $errmsg | append $result.errmsg }} + {{ with $result.node }} + {{ $schema = merge $schema (dict $key .) }} + {{ if isset . "position" }} + {{ $positions = merge $positions (dict (string .position) $key) }} + {{ end }} + {{ end }} +{{ end }} + +{{ return (dict "schema" $schema "positions" $positions "err" (gt (len $errmsg) 0) "errmsg" $errmsg) }} +``` + +- [ ] **Step 2: Compile-error fixture** + +A true cyclic-type fixture is NOT possible without editing the module's global `_arguments.yml` +(forbidden by the global constraints): UDT *members* resolve their types exclusively through the +global arguments file, so a test-only member can never point back to a test-only type. The +`$stack` cycle guard in `compile-node` therefore stays as a defensive invariant (it fires the day +a real cyclic type lands in the shipped data files, and costs nothing meanwhile) and is exercised +here only indirectly. Pin the two reachable compile-error classes instead — record this +limitation as a comment in the case file and in the spec deviation log (Task 11). + +Append to `exampleSite/data/structures/_types.yml` (below the test marker): + +```yaml + test-node: + title: + member-without-global-def: +``` + +Create `exampleSite/data/structures/test-cycle.yml`: + +```yaml +comment: >- + Fixture for schema compile errors: a UDT member without a global argument definition + ('schema: missing type') and an unknown type name ('schema: unknown type'). + A true circular type reference cannot be constructed from test-only data (members resolve + through the module's global _arguments.yml); the compiler's stack guard is defensive. +arguments: + root-node: + type: test-node + optional: true + bogus-typed: + type: no-such-type + optional: true +``` + +- [ ] **Step 3: Extend the runner layout for schema cases** + +In `exampleSite/layouts/tests/single.json`, replace the loop body with: + +```go-html-template +{{- range $case := $data.cases -}} + {{- $entry := dict -}} + {{- if eq $case.api "schema" -}} + {{- $compiled := partial "utilities/ArgsSchema.html" (dict + "structure" ($case.structure | default "") + "bookshop" ($case.bookshop | default "") + "child" ($case.child | default "") + ) -}} + {{- $entry = dict "schema" $compiled -}} + {{- else -}} + {{- $params := dict + "structure" ($case.structure | default "") + "args" $case.args + "named" (ne $case.named false) + -}} + {{- with $case.bookshop }}{{ $params = merge $params (dict "bookshop" . "structure" "") }}{{ end -}} + {{- with $case.child }}{{ $params = merge $params (dict "child" .) }}{{ end -}} + {{- with $case.group }}{{ $params = merge $params (dict "group" .) }}{{ end -}} + {{- $entry = dict "initargs" (partial "utilities/InitArgs.html" $params) -}} + {{- end -}} + {{- $results = merge $results (dict $case.name $entry) -}} +{{- end -}} +``` + +- [ ] **Step 4: Schema case file** + +Create `exampleSite/data/tests/schema.yml`: + +```yaml +cases: + - name: scalar-structure + api: schema + structure: test-cast + - name: nested-structure + api: schema + structure: test-nested + - name: child-merge + api: schema + structure: test-stack + child: test-card + - name: bookshop-blueprint + api: schema + bookshop: test-hero + - name: compile-errors + api: schema + structure: test-cycle + - name: positions + api: schema + structure: test-required +``` + +Create `exampleSite/content/tests/schema.md` (frontmatter pattern from Task 1 Step 3). + +- [ ] **Step 5: Build, inspect the compiled schemas, commit** + +```bash +pnpm test:update && pnpm test # Expected: "golden check passed (11 groups)" +``` + +Inspect `tests/golden/schema.json` line by line against the node contract above. Must-holds: +`nested-structure` shows `heading.children.align` with `default: "start"` and +`locations.children.instructions.children.title` (depth-3 recursion); `child-merge` includes +`hook` WITHOUT a `default` field and excludes `ignored-non-parent`; `bookshop-blueprint` shows +snake declared names, `link_type` carrying the sidecar's select options AND the blueprint default +`button`, plus `_bookshop_name`/`_ordinal`/`id`; `compile-errors` has `err: true` with both +`schema:` messages; `positions` maps `"0" → name`, `"1" → kind`. No existing golden may change. + +```bash +git add layouts exampleSite tests/golden +git commit -m "feat: add ArgsSchema compiler with recursive type resolution" +``` + +--- + +### Task 7: `Args.html` — clean entry point with recursive validator + +**Files:** +- Create: `layouts/_partials/utilities/Args.html` +- Modify: `exampleSite/layouts/tests/single.json` (emit an `args` result next to `initargs`) + +**Interfaces:** +- Consumes: `ArgsSchema.html` contract (Task 6). +- Produces: + +```text +partial "utilities/Args.html" (dict + "structure" S | "bookshop" B [exactly one non-empty] + "child" C "args" MAP-or-SLICE "named" bool(true) "group" G "strict" bool(true)) + → dict "args" (map camelKey → value), "err" bool, "errmsg" []string, + "warnmsg" []string, "defaulted" []string (dotted paths) +``` + +Strictness classes: findings that legacy InitArgs could not detect (nested findings, i.e. depth > 0, +and top-level type/options findings on falsy values) are errors when `strict`, warnings otherwise. +Everything legacy already treated as an error stays an error in both modes. + +- [ ] **Step 1: Write `Args.html`** + +Create `layouts/_partials/utilities/Args.html`: + +```go-html-template + + +{{/* + Validate and initialize arguments against a compiled schema (see utilities/ArgsSchema.html). + Returns a separated envelope: user values never share a namespace with bookkeeping keys. + Keys in "args" are camelCase (canonical); "defaulted" lists dotted paths of defaulted values. +*/}} + +{{ define "_partials/inline/site-param.html" }} + {{/* isset-based site-parameter lookup: an explicit false/0 value is found and honored */}} + {{ $current := site.Params }} + {{ $found := true }} + {{ range $segment := split .path "." }} + {{ if and $found (reflect.IsMap $current) (isset $current $segment) }} + {{ $current = index $current $segment }} + {{ else }} + {{ $found = false }} + {{ end }} + {{ end }} + {{ if not $found }}{{ $current = nil }}{{ end }} + {{ return (dict "found" $found "value" $current) }} +{{ end }} + +{{ define "_partials/inline/value-kind.html" }} + {{/* map a value to its kind token: string, bool, int, float, map, slice, maplist, other */}} + {{ $value := . }} + {{ $type := printf "%T" $value }} + {{ $kind := "other" }} + {{ if eq $type "string" }}{{ $kind = "string" }} + {{ else if eq $type "bool" }}{{ $kind = "bool" }} + {{ else if in (slice "int" "int8" "int16" "int32" "int64" "uint" "uint8" "uint16" "uint32" "uint64") $type }} + {{ $kind = "int" }} + {{ else if in (slice "float32" "float64") $type }}{{ $kind = "float" }} + {{ else if reflect.IsMap $value }}{{ $kind = "map" }} + {{ else if reflect.IsSlice $value }} + {{ $kind = "slice" }} + {{ if gt (len $value) 0 }} + {{ $maps := true }} + {{ range $value }}{{ if not (reflect.IsMap .) }}{{ $maps = false }}{{ break }}{{ end }}{{ end }} + {{ if $maps }}{{ $kind = "maplist" }}{{ end }} + {{ end }} + {{ end }} + {{ return $kind }} +{{ end }} + +{{ define "_partials/inline/validate-node.html" }} + {{/* + in: value, node, path, name, depth (int), provided (bool) + out: dict "value" (with defaults and casts applied; nil when absent without default), + "errmsg" (errors in every mode), "newmsg" (newly detectable class: error when strict, + warning in legacy mode), "warnmsg", "defaulted" (dotted paths) + */}} + {{ $value := .value }} + {{ $node := .node }} + {{ $path := .path }} + {{ $name := .name }} + {{ $depth := .depth }} + {{ $provided := .provided }} + {{ $errmsg := slice }} + {{ $newmsg := slice }} + {{ $warnmsg := slice }} + {{ $defaulted := slice }} + + {{/* deprecation warning when the caller provided an actual value */}} + {{ if and $provided $node.deprecated }} + {{ $warn := printf "[%s] argument '%s': deprecated in v%s" $name $path (strings.TrimPrefix "v" $node.deprecated) }} + {{ with $node.alternative }}{{ $warn = printf "%s, use '%s' instead" $warn . }}{{ end }} + {{ $warnmsg = $warnmsg | append $warn }} + {{ end }} + + {{/* default application: null/absent means "not provided" at every level */}} + {{ if eq $value nil }} + {{ $resolved := false }} + {{ with $node.config }} + {{ $lookup := partial "inline/site-param.html" (dict "path" .) }} + {{ if $lookup.found }}{{ $value = $lookup.value }}{{ $resolved = true }}{{ end }} + {{ end }} + {{ if and (not $resolved) (isset $node "default") }} + {{ $value = $node.default }} + {{ $resolved = true }} + {{ end }} + {{ if $resolved }} + {{ $defaulted = $defaulted | append $path }} + {{ else if $node.children }} + {{/* fill the shape of an absent user-defined type so templates can chain safely */}} + {{ if eq $node.kind "list" }} + {{ $value = slice }} + {{ else }} + {{ $shape := dict }} + {{ range $member, $childNode := $node.children }} + {{ $childRes := partial "inline/validate-node.html" (dict + "value" nil "node" $childNode "path" (printf "%s.%s" $path $member) + "name" $name "depth" (add $depth 1) "provided" false) }} + {{ if ne $childRes.value nil }} + {{ $shape = merge $shape (dict $member $childRes.value) }} + {{ $defaulted = $defaulted | append $childRes.defaulted }} + {{ end }} + {{ end }} + {{ if $shape }}{{ $value = $shape }}{{ end }} + {{ end }} + {{ end }} + {{ if eq $value nil }} + {{ return (dict "value" nil "errmsg" $errmsg "newmsg" $newmsg "warnmsg" $warnmsg "defaulted" $defaulted) }} + {{ end }} + {{ end }} + + {{/* casting between strings and scalars */}} + {{ $kind := partial "inline/value-kind.html" $value }} + {{ if eq $kind "string" }} + {{ if and (in $node.accepts "bool") (in (slice "true" "false") $value) }} + {{ $value = eq $value "true" }} + {{ $kind = "bool" }} + {{ else if and (in $node.accepts "int") (findRE `^-?\d+$` $value) }} + {{ $value = int $value }} + {{ $kind = "int" }} + {{ else if and (in $node.accepts "float") (findRE `^-?(\d+\.\d+|\.\d+)$` $value) }} + {{ $value = float $value }} + {{ $kind = "float" }} + {{ end }} + {{ else if and (in $node.accepts "string") (in (slice "bool" "int" "float") $kind) (not (in $node.accepts $kind)) }} + {{ $value = string $value }} + {{ $kind = "string" }} + {{ end }} + + {{/* type check: applies to every provided value, including explicit false, 0, and "" */}} + {{ if not (in $node.accepts $kind) }} + {{ $msg := printf "[%s] argument '%s': expected type '%s', got '%s' with value '%v'" + $name $path (delimit $node.types ", ") (printf "%T" .value) $value }} + {{ if or (gt $depth 0) (not $value) }} + {{ $newmsg = $newmsg | append $msg }} + {{ else }} + {{ $errmsg = $errmsg | append $msg }} + {{ end }} + {{ return (dict "value" $value "errmsg" $errmsg "newmsg" $newmsg "warnmsg" $warnmsg "defaulted" $defaulted) }} + {{ end }} + + {{/* permitted values and numeric ranges */}} + {{ if and (reflect.IsMap $node.options) $node.options.values (eq $kind "string") }} + {{ if not (in $node.options.values $value) }} + {{ $msg := printf "[%s] argument '%s': unexpected value '%s'" $name $path $value }} + {{ if or (gt $depth 0) (not $value) }} + {{ $newmsg = $newmsg | append $msg }} + {{ else }} + {{ $errmsg = $errmsg | append $msg }} + {{ end }} + {{ end }} + {{ else if and (reflect.IsMap $node.options) + (or (isset $node.options "min") (isset $node.options "max")) + (in (slice "int" "float") $kind) }} + {{ if or + (and (isset $node.options "min") (lt $value $node.options.min)) + (and (isset $node.options "max") (gt $value $node.options.max)) }} + {{ $min := string (or $node.options.min "-") }} + {{ $max := string (or $node.options.max "-") }} + {{ $msg := printf "[%s] argument '%s': value '%v' out of range [%s, %s]" $name $path $value $min $max }} + {{ if or (gt $depth 0) (not $value) }} + {{ $newmsg = $newmsg | append $msg }} + {{ else }} + {{ $errmsg = $errmsg | append $msg }} + {{ end }} + {{ end }} + {{ end }} + + {{/* recursion into user-defined types */}} + {{ if and $node.children (eq $kind "map") }} + {{ $result := partial "inline/validate-members.html" (dict + "value" $value "node" $node "path" $path "name" $name "depth" $depth) }} + {{ $value = $result.value }} + {{ $errmsg = $errmsg | append $result.errmsg }} + {{ $newmsg = $newmsg | append $result.newmsg }} + {{ $warnmsg = $warnmsg | append $result.warnmsg }} + {{ $defaulted = $defaulted | append $result.defaulted }} + {{ else if and $node.children (in (slice "maplist" "slice") $kind) }} + {{ $elements := slice }} + {{ range $index, $element := $value }} + {{ if reflect.IsMap $element }} + {{ $result := partial "inline/validate-members.html" (dict + "value" $element "node" $node + "path" (printf "%s[%d]" $path $index) "name" $name "depth" $depth) }} + {{ $elements = $elements | append $result.value }} + {{ $errmsg = $errmsg | append $result.errmsg }} + {{ $newmsg = $newmsg | append $result.newmsg }} + {{ $warnmsg = $warnmsg | append $result.warnmsg }} + {{ $defaulted = $defaulted | append $result.defaulted }} + {{ else }} + {{ $newmsg = $newmsg | append (printf "[%s] argument '%s[%d]': expected map element, got '%T'" $name $path $index $element) }} + {{ $elements = $elements | append $element }} + {{ end }} + {{ end }} + {{ $value = $elements }} + {{ end }} + + {{ return (dict "value" $value "errmsg" $errmsg "newmsg" $newmsg "warnmsg" $warnmsg "defaulted" $defaulted) }} +{{ end }} + +{{ define "_partials/inline/validate-members.html" }} + {{/* validate the members of one map value against node.children; fills member defaults */}} + {{ $value := .value }} + {{ $node := .node }} + {{ $path := .path }} + {{ $name := .name }} + {{ $depth := .depth }} + {{ $errmsg := slice }} + {{ $newmsg := slice }} + {{ $warnmsg := slice }} + {{ $defaulted := slice }} + {{ $out := dict }} + + {{ range $memberKey, $memberVal := $value }} + {{ $childNode := index $node.children $memberKey }} + {{ $declaredKey := $memberKey }} + {{ if not $childNode }} + {{/* content uses snake_case (bookshop) while members are declared kebab-case, or vice versa */}} + {{ $match := findRESubmatch "^(_*)(.*)$" (string $memberKey) }} + {{ $body := index $match 0 2 }} + {{ $alt := print (index $match 0 1) (cond (strings.Contains $body "_") (replaceRE "_" "-" $body) (replaceRE "-" "_" $body)) }} + {{ with index $node.children $alt }} + {{ $childNode = . }} + {{ $declaredKey = $alt }} + {{ end }} + {{ end }} + {{ if not $childNode }} + {{ $newmsg = $newmsg | append (printf "[%s] argument '%s': unsupported attribute '%s'" $name $path $memberKey) }} + {{ $out = merge $out (dict (string $memberKey) $memberVal) }} + {{ else }} + {{ $childRes := partial "inline/validate-node.html" (dict + "value" $memberVal "node" $childNode + "path" (printf "%s.%s" $path $declaredKey) + "name" $name "depth" (add $depth 1) + "provided" (ne $memberVal nil)) }} + {{ $errmsg = $errmsg | append $childRes.errmsg }} + {{ $newmsg = $newmsg | append $childRes.newmsg }} + {{ $warnmsg = $warnmsg | append $childRes.warnmsg }} + {{ $defaulted = $defaulted | append $childRes.defaulted }} + {{ if ne $childRes.value nil }} + {{ $out = merge $out (dict $declaredKey $childRes.value) }} + {{ end }} + {{ end }} + {{ end }} + + {{/* fill defaults for members the caller omitted entirely */}} + {{ range $memberKey, $childNode := $node.children }} + {{ $snake := replaceRE "-" "_" $memberKey }} + {{ if and (not (isset $out $memberKey)) (not (isset $value $memberKey)) (not (isset $value $snake)) }} + {{ $childRes := partial "inline/validate-node.html" (dict + "value" nil "node" $childNode + "path" (printf "%s.%s" $path $memberKey) + "name" $name "depth" (add $depth 1) "provided" false) }} + {{ if ne $childRes.value nil }} + {{ $out = merge $out (dict $memberKey $childRes.value) }} + {{ $defaulted = $defaulted | append $childRes.defaulted }} + {{ end }} + {{ end }} + {{ end }} + + {{ return (dict "value" $out "errmsg" $errmsg "newmsg" $newmsg "warnmsg" $warnmsg "defaulted" $defaulted) }} +{{ end }} + +{{/* main body */}} +{{ $structure := .structure }} +{{ $bookshop := .bookshop }} +{{ $child := .child }} +{{ $named := .named | default true }} +{{ $args := .args | default dict }} +{{ $group := .group }} +{{ $strict := ne .strict false }} + +{{ $errmsg := slice }} +{{ $newmsg := slice }} +{{ $warnmsg := slice }} +{{ $defaulted := slice }} +{{ $out := dict }} +{{ $name := or $structure $bookshop }} + +{{ $compiled := partial "utilities/ArgsSchema.html" (dict "structure" $structure "bookshop" $bookshop "child" $child) }} +{{ if $compiled.err }} + {{ $errmsg = $errmsg | append $compiled.errmsg }} +{{ else }} + {{ $schema := $compiled.schema }} + + {{/* map positional arguments to their declared names */}} + {{ $provided := dict }} + {{ if $named }} + {{ $provided = $args }} + {{ else }} + {{ range $index, $val := $args }} + {{ with index $compiled.positions (string $index) }} + {{ $provided = merge $provided (dict . $val) }} + {{ else }} + {{ $errmsg = $errmsg | append (printf "[%s] unsupported argument at index %d (value: '%s')" $name $index $val) }} + {{ end }} + {{ end }} + {{ end }} + + {{/* normalize provided keys to declared names; collect all unknown-argument errors */}} + {{ $normalized := dict }} + {{ range $key, $val := $provided }} + {{ if eq $key "_default" }} + {{ $defaulted = $defaulted | append $val }} + {{ else }} + {{ $declared := string $key }} + {{ if and (not (isset $schema $declared)) $bookshop }} + {{ $match := findRESubmatch "^(_*)(.*)$" $declared }} + {{ $body := index $match 0 2 }} + {{ $alt := print (index $match 0 1) (cond (strings.Contains $body "_") (replaceRE "_" "-" $body) (replaceRE "-" "_" $body)) }} + {{ if isset $schema $alt }}{{ $declared = $alt }}{{ end }} + {{ end }} + {{ if and $bookshop (strings.Contains (string $key) "-") (isset $schema $declared) }} + {{ $warnmsg = $warnmsg | append (printf "[%s] argument '%s': prefer snake_case '%s'" $name $key (replaceRE "-" "_" (string $key))) }} + {{ end }} + {{ if not (isset $schema $declared) }} + {{ $errmsg = $errmsg | append (printf "[%s] unsupported argument '%s'" $name $key) }} + {{ else }} + {{ $normalized = merge $normalized (dict $declared $val) }} + {{ end }} + {{ end }} + {{ end }} + + {{/* validate every schema argument and enforce required ones */}} + {{ range $key, $node := $schema }} + {{ $isProvided := isset $normalized $key }} + {{ $val := index $normalized $key }} + {{ $res := partial "inline/validate-node.html" (dict + "value" $val "node" $node "path" $key "name" $name + "depth" 0 "provided" (and $isProvided (ne $val nil))) }} + {{ $errmsg = $errmsg | append $res.errmsg }} + {{ $newmsg = $newmsg | append $res.newmsg }} + {{ $warnmsg = $warnmsg | append $res.warnmsg }} + {{ $defaulted = $defaulted | append $res.defaulted }} + {{ if ne $res.value nil }} + {{ $out = merge $out (dict $node.camelKey $res.value) }} + {{ end }} + + {{ $skip := false }} + {{ if and $group $node.group }}{{ $skip = not (in $node.group $group) }}{{ end }} + {{ if and (not $skip) (not $node.optional) (not $isProvided) }} + {{ $errmsg = $errmsg | append (printf "[%s] argument '%s': expected value" $name $key) }} + {{ end }} + {{ end }} +{{ end }} + +{{ if $strict }} + {{ $errmsg = $errmsg | append $newmsg }} +{{ else }} + {{ $warnmsg = $warnmsg | append $newmsg }} +{{ end }} +{{ return (dict + "args" $out + "err" (gt (len $errmsg) 0) + "errmsg" $errmsg + "warnmsg" $warnmsg + "defaulted" $defaulted +) }} +``` + +- [ ] **Step 2: Emit the strict result next to the legacy result in the runner** + +In `exampleSite/layouts/tests/single.json`, in the non-schema branch, after computing `$entry`, +add the strict-core result so every case golden shows both APIs side by side: + +```go-html-template + {{- $strict := partial "utilities/Args.html" (merge $params (dict "strict" true)) -}} + {{- $entry = merge $entry (dict "args" $strict) -}} +``` + +- [ ] **Step 3: Build, review the golden diff exhaustively** + +```bash +pnpm exec hugo -s exampleSite 2>&1 | head -20 # fix template syntax errors first +pnpm test # Expected: DIFFs — every group gains an "args" key +pnpm test:update && pnpm test # Expected: "golden check passed (11 groups)" +git diff tests/golden +``` + +Review checklist (each item = intended target semantics; anything else is a bug in `Args.html`): +- Every existing `initargs` object is **byte-identical** to before — this step may only ADD `args` keys. Any `initargs` drift means `Args.html` accidentally leaked into the legacy path. +- `envelope/default-arg-collision`: `args.args.default` is `"user-value"` (collision fixed in the clean envelope). +- `defaults/config-false-fallthrough`: strict result shows `fromConfigFalse: false` (BUG(4) fixed). +- `casting/falsy-wrong-type-skips-validation`: strict result has the type error in `errmsg` (BUG(3) fixed). +- `nesting/absent-udt-shape-fill`: strict shows `heading.align: "start"`, `heading.width: 8` — each child's own default (BUG(1) fixed) — and `defaulted` contains `heading.align`, `heading.width`. +- `nesting/nested-wrong-type-passes` and `nested-unknown-key-passes`: strict `errmsg` non-empty (BUG(2) fixed; depth > 0 findings are errors when strict). +- `required/first-error-wins`: strict `errmsg` has BOTH unsupported-argument entries (BUG(6) fixed). +- `bookshop/cloudcannon-null-heavy`: strict `err: false` — nulls stay tolerated (spec §1.3.3). +- `bookshop/cloudcannon-explicit-false`: `show_more`/`width` validated but valid → `err: false`; keys appear camelCased (`showMore`). +- `frontmatter/params-typed-nested-map`: strict handles `maps.Params` without a type error. + +- [ ] **Step 4: Commit** + +```bash +git add layouts exampleSite tests/golden +git commit -m "feat: add Args entry point with recursive validation + +- Fixes nested defaults, zero-deep validation, falsy-value skips, and + or-based config fallthrough on the strict path (spec defects 1-4, 6) +- Adds side-by-side strict results to every golden group" +``` + +--- + +### Task 8: Rewire `InitArgs.html` as a compatibility shim + +**Files:** +- Modify: `layouts/_partials/utilities/InitArgs.html` (full replacement) + +**Interfaces:** +- Consumes: `Args.html` (strict=false) and `ArgsSchema.html` (declared-name ↔ camelKey mapping). +- Produces: the legacy contract — flat map of argument values under declared names plus camelCase + duplicates, merged with `err`, `errmsg`, `warnmsg`, `default` (top-level defaulted names + + `_default` pass-through values). + +- [ ] **Step 1: Replace the body of `InitArgs.html`** + +```go-html-template + + +{{/* + Compatibility shim over utilities/Args.html. Preserves the legacy flat return map: argument + values under their declared names plus camelCase duplicates, merged with the bookkeeping keys + err, errmsg, warnmsg, and default. New code should call utilities/Args.html directly. +*/}} + +{{ $structure := .structure }} +{{ $bookshop := .bookshop }} +{{ $child := .child }} +{{ $named := .named | default true }} +{{ $args := .args | default dict }} +{{ $group := .group }} + +{{ $error := false }} +{{ $errmsg := slice }} +{{ $warnmsg := slice }} +{{ $params := dict }} +{{ $default := slice }} + +{{ if and (not $structure) (not $bookshop) }} + {{ $errmsg = $errmsg | append (printf "partial [utilities/InitArgs.html] - Missing value for param 'structure' or 'bookshop'") }} + {{ $error = true }} +{{ else }} + {{ if hasPrefix $structure "bookshop-" }} + {{ $bookshop = strings.TrimPrefix "bookshop-" $structure }} + {{ $structure = "" }} + {{ end }} + + {{ $res := partial "utilities/Args.html" (dict + "structure" $structure + "bookshop" $bookshop + "child" $child + "args" $args + "named" $named + "group" $group + "strict" false + ) }} + {{ $error = $res.err }} + {{ $errmsg = $res.errmsg }} + {{ $warnmsg = $res.warnmsg }} + + {{ if not $error }} + {{ $compiled := partial "utilities/ArgsSchema.html" (dict "structure" $structure "bookshop" $bookshop "child" $child) }} + {{ range $key, $node := $compiled.schema }} + {{ $val := index $res.args $node.camelKey }} + {{ if ne $val nil }} + {{ $params = merge $params (dict $key $val) }} + {{ if ne $node.camelKey $key }} + {{ $params = merge $params (dict $node.camelKey $val) }} + {{ end }} + {{ end }} + {{ end }} + {{ range $res.defaulted }} + {{ if not (strings.Contains . ".") }}{{ $default = $default | append . }}{{ end }} + {{ end }} + {{ end }} +{{ end }} + +{{ $params = merge $params (dict "err" $error "errmsg" $errmsg "warnmsg" $warnmsg "default" $default) }} +{{ return $params }} +``` + +- [ ] **Step 2: Build and review the golden diff — the decisive gate of the whole effort** + +```bash +pnpm test # Expected: DIFFs in the initargs halves +git --no-pager diff # nothing yet — inspect the console diff, then: +pnpm test:update && git diff tests/golden +``` + +Every changed `initargs` line must belong to one of these INTENDED categories — anything else +means the shim diverges and must be fixed before committing: + +1. **BUG(1) fix:** `nesting/absent-udt-shape-fill` now shows real child defaults + (`align: "start"`, `width: 8`) instead of nulls. +2. **New warnings (strictness downgraded):** nested findings and falsy-value type findings appear + in `warnmsg` (NOT `errmsg`) with `err: false` — e.g. `casting/falsy-wrong-type-skips-validation`, + `nesting/nested-wrong-type-passes`, `nested-unknown-key-passes`. +3. **BUG(6) fix:** `required/first-error-wins` reports both errors. +4. **BUG(4) fix:** `defaults/config-false-fallthrough` honors the explicit false site param. +5. **Additive camelCase keys:** snake_case arguments now ALSO appear under camelCase keys + (legacy only duplicated hyphenated names). Additive only — the snake keys must remain. +6. **Nulls dropped from nested maps:** provided-but-null members no longer appear as explicit + nulls inside nested values (they were pass-through before). `err` must remain false. +7. **Sidecar default preserved:** bookshop args whose blueprint scalar default was previously + discarded by the sidecar merge now show that default. +8. **Envelope collision:** `envelope/default-arg-collision` — the bookkeeping `default` slice still + wins in the legacy map (collision is inherent to the flat envelope; only `Args.html` fixes it). + If the legacy value changed shape here, reconcile against the Task 1 golden. +9. **Error-case payloads:** legacy broke on the first error and returned the arguments it had + already processed; the shim returns only bookkeeping keys when `err` is true. Callers bail on + `err` before touching values, so this payload difference is inert — but it WILL show in the + goldens of error cases. + +Also confirm: `bookshop/cloudcannon-null-heavy` still `err: false`; `child/child-args-cascade` +unchanged except intended categories; NO change in any `args` (strict) half. + +- [ ] **Step 3: Integration smoke test against Hinode** + +```bash +cd /Users/mark/Development/GitHub/gethinode/hinode +HUGO_MODULE_REPLACEMENTS="github.com/gethinode/mod-utils/v5 -> /Users/mark/Development/GitHub/gethinode/mod-utils" \ + hugo --ignoreVendorPaths "github.com/gethinode/mod-utils/**" -s exampleSite -d /tmp/hinode-smoke --printI18nWarnings 2>&1 | tee /tmp/hinode-smoke.log +cd /Users/mark/Development/GitHub/gethinode/mod-utils +``` + +Expected: build SUCCEEDS. Grep `/tmp/hinode-smoke.log` for `WARN`: new warnings from the +downgraded strictness classes are expected and must be listed in the commit body (they are the +"warnings-first" payload); any ERROR is a shim bug — fix before committing. + +- [ ] **Step 4: Commit** + +```bash +git add layouts tests/golden exampleSite +git commit -m "refactor: rewire InitArgs as a compatibility shim over Args + +BREAKING-ISH (warnings only): newly detectable validation findings +(nested type mismatches, unknown nested attributes, falsy-value type +mismatches) surface as warnings; promotion to errors follows one +release cycle later per the design spec. + +Golden diffs in this commit are intentional: nested defaults fixed, +all errors reported (no first-error break), explicit-false site +params honored, camelCase duplicates added for snake_case keys. + +" +``` + +--- + +### Task 9: Rewire `InitTypes.html` as a compatibility shim + +**Files:** +- Modify: `layouts/_partials/utilities/InitTypes.html` (full replacement) +- Create: `exampleSite/data/tests/inittypes.yml`, `exampleSite/content/tests/inittypes.md` +- Modify: `exampleSite/layouts/tests/single.json` (support `api: inittypes`) + +**Interfaces:** +- Consumes: `ArgsSchema.html`. +- Produces: legacy contract used by Hinode's `assets/args.html` (verified consumer): + `{types: map name → legacy def, udt: map TYPE-name → {_reflect, member → legacy def}, err, errmsg, warnmsg}`. + Legacy def fields: `type` (string when single, slice when union), `optional`, and — when defined — + `default`, `config`, `options`, `position`, `group` (string when single), `deprecated`, + `alternative`, `release`, `parent`, `comment`. `_reflect` is `"map[string]interface {}"` for dict + UDTs and `"[]interface {}"` for list UDTs (matches `assets/args.html:41`). Only top-level UDTs + appear in `udt` (legacy behavior — nested UDTs of members are not lifted). + +- [ ] **Step 1: Add the characterization group FIRST (against the legacy implementation)** + +Extend the runner layout's case dispatch with an `inittypes` branch (mirroring the `schema` branch): + +```go-html-template + {{- else if eq $case.api "inittypes" -}} + {{- $entry = dict "inittypes" (partial "utilities/InitTypes.html" (dict + "structure" ($case.structure | default "") + "bookshop" ($case.bookshop | default "") + "child" ($case.child | default "") + )) -}} +``` + +Create `exampleSite/data/tests/inittypes.yml`: + +```yaml +cases: + - name: scalar-structure + api: inittypes + structure: test-cast + - name: nested-structure + api: inittypes + structure: test-nested + - name: bookshop-component + api: inittypes + bookshop: test-hero + - name: child-merge + api: inittypes + structure: test-stack + child: test-card +``` + +Create `exampleSite/content/tests/inittypes.md` (frontmatter pattern from Task 1 Step 3). Then: + +```bash +pnpm test:update && pnpm test # Expected: "golden check passed (12 groups)" +git add exampleSite tests/golden +git commit -m "test: characterize the legacy InitTypes contract" +``` + +- [ ] **Step 2: Replace `InitTypes.html`** + +```go-html-template + + +{{/* + Compatibility shim over utilities/ArgsSchema.html. Reproduces the legacy return shape + {types, udt, err, errmsg, warnmsg}. Deprecated: new code should consume ArgsSchema directly. +*/}} + +{{ define "_partials/inline/legacy-def.html" }} + {{ $node := . }} + {{ $type := $node.types }} + {{ if eq (len $node.types) 1 }}{{ $type = index $node.types 0 }}{{ end }} + {{ $def := dict "type" $type "optional" $node.optional }} + {{ range $field := slice "default" "config" "options" "position" "deprecated" "alternative" "release" "parent" "comment" }} + {{ if isset $node $field }}{{ $def = merge $def (dict $field (index $node $field)) }}{{ end }} + {{ end }} + {{ with $node.group }} + {{ $group := . }} + {{ if eq (len .) 1 }}{{ $group = index . 0 }}{{ end }} + {{ $def = merge $def (dict "group" $group) }} + {{ end }} + {{ return $def }} +{{ end }} + +{{ $compiled := partial "utilities/ArgsSchema.html" (dict + "structure" (.structure | default "") + "bookshop" (.bookshop | default "") + "child" (.child | default "") +) }} + +{{ $types := dict }} +{{ $udt := dict }} +{{ range $key, $node := $compiled.schema }} + {{ $types = merge $types (dict $key (partial "inline/legacy-def.html" $node)) }} + {{ if $node.children }} + {{ $reflect := "map[string]interface {}" }} + {{ if eq $node.kind "list" }}{{ $reflect = "[]interface {}" }}{{ end }} + {{ $members := dict "_reflect" $reflect }} + {{ range $member, $childNode := $node.children }} + {{ $members = merge $members (dict $member (partial "inline/legacy-def.html" $childNode)) }} + {{ end }} + {{ $udt = merge $udt (dict $node.udtType $members) }} + {{ end }} +{{ end }} + +{{ return (dict "types" $types "udt" $udt "err" $compiled.err "errmsg" $compiled.errmsg "warnmsg" slice) }} +``` + +- [ ] **Step 3: Diff against the Step 1 characterization, reconcile, commit** + +```bash +pnpm test && true; pnpm test:update && git diff tests/golden/inittypes.json +``` + +Reconcile every diff: acceptable categories are (a) messages now carrying the `schema:` prefix, +(b) field ordering inside defs (jsonify sorts — should be nil diff), (c) the sidecar-default fix +from Task 6. Legacy `udt` was keyed by TYPE name with `_reflect` — verify `nested-structure` +shows `udt.heading._reflect == "map[string]interface {}"` and `udt.locations._reflect == "[]interface {}"`. +Then rebuild the Hinode smoke test (same command as Task 8 Step 3) and additionally check a docs +page that uses the args table renders: grep the build log for errors mentioning `args.html`. + +```bash +git add layouts tests/golden +git commit -m "refactor: rewire InitTypes as a compatibility shim over ArgsSchema" +``` + +--- + +### Task 10: Enable `partialCached` for schema compilation and measure + +**Files:** +- Modify: `layouts/_partials/utilities/Args.html` (one call site) +- Modify: `layouts/_partials/utilities/InitArgs.html` (one call site) +- Modify: `layouts/_partials/utilities/InitTypes.html` (one call site) + +**Interfaces:** unchanged signatures; `ArgsSchema.html` is now invoked as +`partialCached "utilities/ArgsSchema.html" $opts (or $structure "") (or $bookshop "") (or $child "")`. + +- [ ] **Step 1: Capture the BEFORE metrics** + +```bash +cd /Users/mark/Development/GitHub/gethinode/hinode +HUGO_MODULE_REPLACEMENTS="github.com/gethinode/mod-utils/v5 -> /Users/mark/Development/GitHub/gethinode/mod-utils" \ + hugo --ignoreVendorPaths "github.com/gethinode/mod-utils/**" -s exampleSite -d /tmp/hinode-metrics \ + --templateMetrics 2>&1 | grep -E "cumulative|InitArgs|InitTypes|ArgsSchema|Args.html" | head -20 | tee /tmp/metrics-before.txt +cd /Users/mark/Development/GitHub/gethinode/mod-utils +``` + +- [ ] **Step 2: Switch the three call sites** + +In each of `Args.html`, `InitArgs.html`, `InitTypes.html`, replace the +`partial "utilities/ArgsSchema.html" (dict …)` invocation with: + +```go-html-template +{{ $compiled := partialCached "utilities/ArgsSchema.html" + (dict "structure" $structure "bookshop" $bookshop "child" $child) + (or $structure "") (or $bookshop "") (or $child "") }} +``` + +(In `InitTypes.html` the option values are the `.structure | default ""` forms already computed — +bind them to variables first so the cache variants are plain strings.) + +- [ ] **Step 3: Verify zero behavior drift, capture AFTER metrics** + +```bash +pnpm test # Expected: "golden check passed (12 groups)" — caching must not change ANY golden +``` + +Re-run the Step 1 metrics command into `/tmp/metrics-after.txt`. Expected: `ArgsSchema.html` +cumulative time drops sharply (cache hits); record both numbers in the commit body. + +- [ ] **Step 4: Commit** + +```bash +git add layouts +git commit -m "perf: cache compiled argument schemas per structure + +Hinode exampleSite --templateMetrics, ArgsSchema.html cumulative: +before , after ." +``` + +--- + +### Task 11: Documentation and release preparation + +**Files:** +- Modify: `README.md` (document `Args.html`, `ArgsSchema.html`, shim status of `InitArgs`/`InitTypes`, the null-vs-explicit semantics, and the warnings-first strictness rollout) +- Modify: `docs/superpowers/specs/2026-07-12-args-type-system-redesign-design.md` (status: Implemented (phases 0–4); record deviations discovered during execution, e.g. the cycle-fixture limitation from Task 6 Step 2) + +- [ ] **Step 1: Write the README section** + +Add under a `## Argument validation` heading: the `Args.html` signature and envelope, the +`ArgsSchema.html` node contract (copy from Task 6 Interfaces), a migration note ("InitArgs and +InitTypes remain supported as shims within v5"), the null-tolerance rule, and the list of warning +classes that will become errors after one release cycle. + +- [ ] **Step 2: Full verification** + +```bash +pnpm test # golden check passed (12 groups) +pnpm build # exampleSite builds clean +``` + +Re-run the Hinode smoke build (Task 8 Step 3) one final time; confirm warnings unchanged since Task 8. + +- [ ] **Step 3: Commit and open the PR** + +```bash +git add README.md docs +git commit -m "docs: document the Args validation API and strictness rollout" +git push -u origin HEAD +gh pr create --repo gethinode/mod-utils --base main \ + --title "feat: redesign argument and type system (core + shims, golden-tested)" \ + --body " + +🤖 Generated with [Claude Code](https://claude.com/claude-code)" +``` + +--- + +## Deferred (explicitly OUT of this plan — spec phase 5) + +- Migrating Hinode/first-party module call sites to `Args.html`. +- `args.md` docs shortcode consuming `ArgsSchema`. +- Seeding mod-blocks' exampleSite with representative block content. +- Promoting the downgraded warning classes to errors (one release cycle later). +- Manual CloudCannon live-editing verification on a connected site (release gate, human task). From f77c8f153b7e4d3ae0ade48cfd24d6ac03379bf8 Mon Sep 17 00:00:00 2001 From: Mark Dumay <61946753+markdumay@users.noreply.github.com> Date: Sun, 12 Jul 2026 07:21:41 +0200 Subject: [PATCH 04/21] test: add golden-file harness with envelope characterization - Add data mount to exampleSite/hugo.toml to expose module's data files - Create test-runner layout at exampleSite/layouts/tests/single.json to render InitArgs result envelopes as JSON - Create test fixture files: structure, case group, and content page for envelope characterization test - Add golden comparison script (tests/golden.mjs) for comparing generated test output against committed golden files - Update package.json scripts: add pretest, test, and test:update commands - Generate initial golden baseline that characterizes BUG(5): user argument 'default' is clobbered by bookkeeping 'default' slice Co-Authored-By: Claude Haiku 4.5 --- exampleSite/content/tests/envelope.md | 4 ++ exampleSite/data/structures/test-envelope.yml | 10 +++ exampleSite/data/tests/envelope.yml | 10 +++ exampleSite/hugo.toml | 5 +- exampleSite/layouts/tests/single.json | 21 ++++++ package.json | 4 +- tests/golden.mjs | 65 +++++++++++++++++++ tests/golden/envelope.json | 19 ++++++ 8 files changed, 136 insertions(+), 2 deletions(-) create mode 100644 exampleSite/content/tests/envelope.md create mode 100644 exampleSite/data/structures/test-envelope.yml create mode 100644 exampleSite/data/tests/envelope.yml create mode 100644 exampleSite/layouts/tests/single.json create mode 100644 tests/golden.mjs create mode 100644 tests/golden/envelope.json diff --git a/exampleSite/content/tests/envelope.md b/exampleSite/content/tests/envelope.md new file mode 100644 index 0000000..e7da0ce --- /dev/null +++ b/exampleSite/content/tests/envelope.md @@ -0,0 +1,4 @@ +--- +title: Envelope +outputs: ["json"] +--- diff --git a/exampleSite/data/structures/test-envelope.yml b/exampleSite/data/structures/test-envelope.yml new file mode 100644 index 0000000..4cf4e32 --- /dev/null +++ b/exampleSite/data/structures/test-envelope.yml @@ -0,0 +1,10 @@ +comment: >- + Test fixture demonstrating the envelope namespace collision: an argument named + 'default' shares the return map with the bookkeeping 'default' slice. +arguments: + default: + type: string + optional: true + plain: + type: string + optional: true diff --git a/exampleSite/data/tests/envelope.yml b/exampleSite/data/tests/envelope.yml new file mode 100644 index 0000000..3d342a1 --- /dev/null +++ b/exampleSite/data/tests/envelope.yml @@ -0,0 +1,10 @@ +cases: + - name: plain-arg + structure: test-envelope + args: + plain: hello + # BUG(5): the user's value for 'default' is clobbered by the bookkeeping merge + - name: default-arg-collision + structure: test-envelope + args: + default: user-value diff --git a/exampleSite/hugo.toml b/exampleSite/hugo.toml index ccbc6c8..45881cf 100644 --- a/exampleSite/hugo.toml +++ b/exampleSite/hugo.toml @@ -11,4 +11,7 @@ title = 'Test site for mod-utils' target = "static" [[module.imports.mounts]] source = "layouts" - target = "layouts" \ No newline at end of file + target = "layouts" + [[module.imports.mounts]] + source = "data" + target = "data" \ No newline at end of file diff --git a/exampleSite/layouts/tests/single.json b/exampleSite/layouts/tests/single.json new file mode 100644 index 0000000..857418f --- /dev/null +++ b/exampleSite/layouts/tests/single.json @@ -0,0 +1,21 @@ +{{- /* + Test runner: renders the InitArgs result envelope for every case in the group's case file + (data/tests/.yml) or, when the page defines cases in frontmatter, from .Params.cases. + Output is canonical JSON (jsonify sorts map keys) compared against tests/golden/.json. +*/ -}} +{{- $group := .File.ContentBaseName -}} +{{- $data := index site.Data.tests $group | default .Params -}} +{{- $results := dict -}} +{{- range $case := $data.cases -}} + {{- $params := dict + "structure" ($case.structure | default "") + "args" $case.args + "named" (ne $case.named false) + -}} + {{- with $case.bookshop }}{{ $params = merge $params (dict "bookshop" . "structure" "") }}{{ end -}} + {{- with $case.child }}{{ $params = merge $params (dict "child" .) }}{{ end -}} + {{- with $case.group }}{{ $params = merge $params (dict "group" .) }}{{ end -}} + {{- $legacy := partial "utilities/InitArgs.html" $params -}} + {{- $results = merge $results (dict $case.name (dict "initargs" $legacy)) -}} +{{- end -}} +{{- jsonify (dict "indent" " ") $results -}} diff --git a/package.json b/package.json index bcf7664..e68ee5f 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,9 @@ "mod:tidy": "hugo mod tidy", "mod:vendor": "rimraf _vendor && hugo mod vendor", "prepare": "node .husky/install.mjs", - "test": "pnpm build" + "pretest": "pnpm clean && pnpm mod:vendor", + "test": "hugo -s exampleSite && node tests/golden.mjs", + "test:update": "pnpm clean && pnpm mod:vendor && hugo -s exampleSite && node tests/golden.mjs --update" }, "repository": { "type": "git", diff --git a/tests/golden.mjs b/tests/golden.mjs new file mode 100644 index 0000000..d0fbd42 --- /dev/null +++ b/tests/golden.mjs @@ -0,0 +1,65 @@ +#!/usr/bin/env node +// Compares generated test output (exampleSite/public/tests//index.json) against the +// committed golden files (tests/golden/.json). Run with --update to (re)write goldens. +import {existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync} from 'node:fs'; +import path from 'node:path'; +import process from 'node:process'; + +const generatedRoot = path.join('exampleSite', 'public', 'tests'); +const goldenRoot = path.join('tests', 'golden'); +const update = process.argv.includes('--update'); + +const groups = readdirSync(generatedRoot, {withFileTypes: true}) + .filter((entry) => entry.isDirectory() + && existsSync(path.join(generatedRoot, entry.name, 'index.json'))) + .map((entry) => entry.name); + +if (groups.length === 0) { + console.error(`no generated test output found under ${generatedRoot}`); + process.exit(1); +} + +let failed = false; +const seen = new Set(); +for (const group of groups) { + const generated = readFileSync(path.join(generatedRoot, group, 'index.json'), 'utf8'); + const goldenFile = path.join(goldenRoot, `${group}.json`); + seen.add(`${group}.json`); + if (update) { + mkdirSync(goldenRoot, {recursive: true}); + writeFileSync(goldenFile, generated); + console.log(`updated ${goldenFile}`); + continue; + } + + if (!existsSync(goldenFile)) { + console.error(`MISSING golden: ${goldenFile} (run: pnpm test:update)`); + failed = true; + continue; + } + + const golden = readFileSync(goldenFile, 'utf8'); + if (golden !== generated) { + failed = true; + console.error(`DIFF in group '${group}' (golden vs generated):`); + const a = golden.split('\n'); + const b = generated.split('\n'); + for (let i = 0; i < Math.max(a.length, b.length); i++) { + if (a[i] !== b[i]) { + console.error(` line ${i + 1}:\n - ${a[i] ?? ''}\n + ${b[i] ?? ''}`); + } + } + } +} + +if (!update && existsSync(goldenRoot)) { + for (const file of readdirSync(goldenRoot)) { + if (!seen.has(file)) { + console.error(`ORPHAN golden without generated output: ${file}`); + failed = true; + } + } +} + +if (failed) process.exit(1); +console.log(`golden check passed (${groups.length} groups)`); diff --git a/tests/golden/envelope.json b/tests/golden/envelope.json new file mode 100644 index 0000000..fac1c42 --- /dev/null +++ b/tests/golden/envelope.json @@ -0,0 +1,19 @@ +{ + "default-arg-collision": { + "initargs": { + "default": [], + "err": false, + "errmsg": [], + "warnmsg": [] + } + }, + "plain-arg": { + "initargs": { + "default": [], + "err": false, + "errmsg": [], + "plain": "hello", + "warnmsg": [] + } + } +} \ No newline at end of file From 0702e4f39b7a380626142559e9a34ad09a84b24f Mon Sep 17 00:00:00 2001 From: Mark Dumay <61946753+markdumay@users.noreply.github.com> Date: Sun, 12 Jul 2026 07:28:35 +0200 Subject: [PATCH 05/21] test: characterize defaults, casting, and options behavior - Add test fixtures for static, config-based, and deprecated defaults - Add test fixtures for type casting and collection types - Add test fixtures for select validation and numeric ranges - Capture BUG(3): explicit false skips type validation - Capture BUG(4): false config value falls through to static default - Document latent quirks: empty string matches float regex, float64 type handling Co-Authored-By: Claude Fable 5 --- exampleSite/content/tests/casting.md | 4 + exampleSite/content/tests/defaults.md | 4 + exampleSite/content/tests/options.md | 4 + exampleSite/data/structures/test-cast.yml | 20 +++ exampleSite/data/structures/test-default.yml | 31 +++++ exampleSite/data/structures/test-options.yml | 14 +++ exampleSite/data/tests/casting.yml | 45 +++++++ exampleSite/data/tests/defaults.yml | 17 +++ exampleSite/data/tests/options.yml | 19 +++ exampleSite/hugo.toml | 5 + tests/golden/casting.json | 126 +++++++++++++++++++ tests/golden/defaults.json | 125 ++++++++++++++++++ tests/golden/options.json | 69 ++++++++++ 13 files changed, 483 insertions(+) create mode 100644 exampleSite/content/tests/casting.md create mode 100644 exampleSite/content/tests/defaults.md create mode 100644 exampleSite/content/tests/options.md create mode 100644 exampleSite/data/structures/test-cast.yml create mode 100644 exampleSite/data/structures/test-default.yml create mode 100644 exampleSite/data/structures/test-options.yml create mode 100644 exampleSite/data/tests/casting.yml create mode 100644 exampleSite/data/tests/defaults.yml create mode 100644 exampleSite/data/tests/options.yml create mode 100644 tests/golden/casting.json create mode 100644 tests/golden/defaults.json create mode 100644 tests/golden/options.json diff --git a/exampleSite/content/tests/casting.md b/exampleSite/content/tests/casting.md new file mode 100644 index 0000000..66d6cdf --- /dev/null +++ b/exampleSite/content/tests/casting.md @@ -0,0 +1,4 @@ +--- +title: casting +outputs: ["json"] +--- diff --git a/exampleSite/content/tests/defaults.md b/exampleSite/content/tests/defaults.md new file mode 100644 index 0000000..e3bddd0 --- /dev/null +++ b/exampleSite/content/tests/defaults.md @@ -0,0 +1,4 @@ +--- +title: defaults +outputs: ["json"] +--- diff --git a/exampleSite/content/tests/options.md b/exampleSite/content/tests/options.md new file mode 100644 index 0000000..6966c65 --- /dev/null +++ b/exampleSite/content/tests/options.md @@ -0,0 +1,4 @@ +--- +title: options +outputs: ["json"] +--- diff --git a/exampleSite/data/structures/test-cast.yml b/exampleSite/data/structures/test-cast.yml new file mode 100644 index 0000000..9e8c9cd --- /dev/null +++ b/exampleSite/data/structures/test-cast.yml @@ -0,0 +1,20 @@ +comment: Test fixture for type casting, scalar validation, and generic collection types. +arguments: + flag: + type: bool + optional: true + count: + type: int + optional: true + ratio-value: + type: float + optional: true + label: + type: string + optional: true + data: + type: dict + optional: true + items-list: + type: slice + optional: true diff --git a/exampleSite/data/structures/test-default.yml b/exampleSite/data/structures/test-default.yml new file mode 100644 index 0000000..5668e73 --- /dev/null +++ b/exampleSite/data/structures/test-default.yml @@ -0,0 +1,31 @@ +comment: Test fixture for static, config-based, and deprecated defaults. +arguments: + static-label: + type: string + optional: true + default: fallback + from-config: + type: string + optional: true + config: test.label + default: static-fallback + from-config-true: + type: bool + optional: true + config: test.enabled + default: false + from-config-false: + type: bool + optional: true + config: test.disabled + default: true + from-config-missing: + type: string + optional: true + config: test.does-not-exist + default: static-fallback + legacy-arg: + type: string + optional: true + deprecated: 1.2.0 + alternative: static-label diff --git a/exampleSite/data/structures/test-options.yml b/exampleSite/data/structures/test-options.yml new file mode 100644 index 0000000..8364bca --- /dev/null +++ b/exampleSite/data/structures/test-options.yml @@ -0,0 +1,14 @@ +comment: Test fixture for permitted values and numeric ranges. +arguments: + mode: + type: select + optional: true + default: auto + options: + values: [auto, manual] + size: + type: int + optional: true + options: + min: 1 + max: 10 diff --git a/exampleSite/data/tests/casting.yml b/exampleSite/data/tests/casting.yml new file mode 100644 index 0000000..f0ded1a --- /dev/null +++ b/exampleSite/data/tests/casting.yml @@ -0,0 +1,45 @@ +cases: + - name: string-to-bool + structure: test-cast + args: {flag: "true"} + - name: string-to-int + structure: test-cast + args: {count: "42"} + - name: string-to-float + structure: test-cast + args: {ratio-value: "1.5"} + - name: int-to-string + structure: test-cast + args: {label: 7} + - name: bool-arg-real-bool + structure: test-cast + args: {flag: true} + # BUG(3): explicit false skips type validation entirely — a bool false into an int arg passes + - name: falsy-wrong-type-skips-validation + structure: test-cast + args: {count: false} + - name: wrong-type-errors + structure: test-cast + args: {count: "abc"} + # Latent quirk: type 'float' expects reflect name 'float', but real floats reflect as 'float64' + - name: real-float-value + structure: test-cast + args: {ratio-value: 1.5} + # Latent quirk: the legacy float regex matches the empty string + - name: empty-string-float + structure: test-cast + args: {ratio-value: ""} + - name: generic-dict-as-map + structure: test-cast + args: + data: {anything: goes} + # Legacy quirk kept on purpose: type 'dict' also accepts a slice of maps + - name: generic-dict-as-maplist + structure: test-cast + args: + data: + - anything: goes + - name: generic-slice + structure: test-cast + args: + items-list: [1, "two", true] diff --git a/exampleSite/data/tests/defaults.yml b/exampleSite/data/tests/defaults.yml new file mode 100644 index 0000000..3b37267 --- /dev/null +++ b/exampleSite/data/tests/defaults.yml @@ -0,0 +1,17 @@ +cases: + - name: static-default-applied + structure: test-default + args: {} + - name: static-default-overridden + structure: test-default + args: {static-label: custom} + - name: config-present-wins + structure: test-default + args: {} + # BUG(4): site param explicitly false falls through to the static default (or-based lookup) + - name: config-false-fallthrough + structure: test-default + args: {} + - name: deprecated-warns + structure: test-default + args: {legacy-arg: old} diff --git a/exampleSite/data/tests/options.yml b/exampleSite/data/tests/options.yml new file mode 100644 index 0000000..a93e706 --- /dev/null +++ b/exampleSite/data/tests/options.yml @@ -0,0 +1,19 @@ +cases: + - name: select-valid + structure: test-options + args: {mode: manual} + - name: select-invalid + structure: test-options + args: {mode: bogus} + - name: select-default + structure: test-options + args: {} + - name: range-inside + structure: test-options + args: {size: 5} + - name: range-below-min + structure: test-options + args: {size: 0} + - name: range-above-max + structure: test-options + args: {size: 11} diff --git a/exampleSite/hugo.toml b/exampleSite/hugo.toml index 45881cf..f1abae5 100644 --- a/exampleSite/hugo.toml +++ b/exampleSite/hugo.toml @@ -2,6 +2,11 @@ baseURL = 'http://example.org/' languageCode = 'en-us' title = 'Test site for mod-utils' +[params.test] + enabled = true + disabled = false + label = "from-site-params" + [module] replacements = 'github.com/gethinode/mod-utils -> ../..' [[module.imports]] diff --git a/tests/golden/casting.json b/tests/golden/casting.json new file mode 100644 index 0000000..a74befe --- /dev/null +++ b/tests/golden/casting.json @@ -0,0 +1,126 @@ +{ + "bool-arg-real-bool": { + "initargs": { + "default": [], + "err": false, + "errmsg": [], + "flag": true, + "warnmsg": [] + } + }, + "empty-string-float": { + "initargs": { + "default": [], + "err": false, + "errmsg": [], + "ratio-value": 0, + "ratioValue": 0, + "warnmsg": [] + } + }, + "falsy-wrong-type-skips-validation": { + "initargs": { + "count": false, + "default": [], + "err": false, + "errmsg": [], + "warnmsg": [] + } + }, + "generic-dict-as-map": { + "initargs": { + "default": [], + "err": true, + "errmsg": [ + "[test-cast] argument 'data': expected type 'dict, []map[string]interface {}', got 'map[string]interface {}' with value 'map[anything:goes]'" + ], + "warnmsg": [] + } + }, + "generic-dict-as-maplist": { + "initargs": { + "default": [], + "err": true, + "errmsg": [ + "[test-cast] argument 'data': expected type 'dict, []map[string]interface {}', got '[]interface {}' with value '[map[anything:goes]]'" + ], + "warnmsg": [] + } + }, + "generic-slice": { + "initargs": { + "default": [], + "err": false, + "errmsg": [], + "items-list": [ + 1, + "two", + true + ], + "itemsList": [ + 1, + "two", + true + ], + "warnmsg": [] + } + }, + "int-to-string": { + "initargs": { + "default": [], + "err": true, + "errmsg": [ + "[test-cast] argument 'label': expected type 'string', got 'uint64' with value '7'" + ], + "warnmsg": [] + } + }, + "real-float-value": { + "initargs": { + "default": [], + "err": false, + "errmsg": [], + "ratio-value": 1.5, + "ratioValue": 1.5, + "warnmsg": [] + } + }, + "string-to-bool": { + "initargs": { + "default": [], + "err": false, + "errmsg": [], + "flag": true, + "warnmsg": [] + } + }, + "string-to-float": { + "initargs": { + "default": [], + "err": false, + "errmsg": [], + "ratio-value": 1.5, + "ratioValue": 1.5, + "warnmsg": [] + } + }, + "string-to-int": { + "initargs": { + "count": 42, + "default": [], + "err": false, + "errmsg": [], + "warnmsg": [] + } + }, + "wrong-type-errors": { + "initargs": { + "default": [], + "err": true, + "errmsg": [ + "[test-cast] argument 'count': expected type 'int', got 'string' with value 'abc'" + ], + "warnmsg": [] + } + } +} \ No newline at end of file diff --git a/tests/golden/defaults.json b/tests/golden/defaults.json new file mode 100644 index 0000000..ce20d15 --- /dev/null +++ b/tests/golden/defaults.json @@ -0,0 +1,125 @@ +{ + "config-false-fallthrough": { + "initargs": { + "default": [ + "from-config", + "from-config-false", + "from-config-missing", + "from-config-true", + "static-label" + ], + "err": false, + "errmsg": [], + "from-config": "from-site-params", + "from-config-false": true, + "from-config-missing": "static-fallback", + "from-config-true": true, + "fromConfig": "from-site-params", + "fromConfigFalse": true, + "fromConfigMissing": "static-fallback", + "fromConfigTrue": true, + "static-label": "fallback", + "staticLabel": "fallback", + "warnmsg": [] + } + }, + "config-present-wins": { + "initargs": { + "default": [ + "from-config", + "from-config-false", + "from-config-missing", + "from-config-true", + "static-label" + ], + "err": false, + "errmsg": [], + "from-config": "from-site-params", + "from-config-false": true, + "from-config-missing": "static-fallback", + "from-config-true": true, + "fromConfig": "from-site-params", + "fromConfigFalse": true, + "fromConfigMissing": "static-fallback", + "fromConfigTrue": true, + "static-label": "fallback", + "staticLabel": "fallback", + "warnmsg": [] + } + }, + "deprecated-warns": { + "initargs": { + "default": [ + "from-config", + "from-config-false", + "from-config-missing", + "from-config-true", + "static-label" + ], + "err": false, + "errmsg": [], + "from-config": "from-site-params", + "from-config-false": true, + "from-config-missing": "static-fallback", + "from-config-true": true, + "fromConfig": "from-site-params", + "fromConfigFalse": true, + "fromConfigMissing": "static-fallback", + "fromConfigTrue": true, + "legacy-arg": "old", + "legacyArg": "old", + "static-label": "fallback", + "staticLabel": "fallback", + "warnmsg": [ + "[test-default] argument 'legacy-arg': deprecated in v1.2.0, use 'static-label' instead" + ] + } + }, + "static-default-applied": { + "initargs": { + "default": [ + "from-config", + "from-config-false", + "from-config-missing", + "from-config-true", + "static-label" + ], + "err": false, + "errmsg": [], + "from-config": "from-site-params", + "from-config-false": true, + "from-config-missing": "static-fallback", + "from-config-true": true, + "fromConfig": "from-site-params", + "fromConfigFalse": true, + "fromConfigMissing": "static-fallback", + "fromConfigTrue": true, + "static-label": "fallback", + "staticLabel": "fallback", + "warnmsg": [] + } + }, + "static-default-overridden": { + "initargs": { + "default": [ + "from-config", + "from-config-false", + "from-config-missing", + "from-config-true" + ], + "err": false, + "errmsg": [], + "from-config": "from-site-params", + "from-config-false": true, + "from-config-missing": "static-fallback", + "from-config-true": true, + "fromConfig": "from-site-params", + "fromConfigFalse": true, + "fromConfigMissing": "static-fallback", + "fromConfigTrue": true, + "static-label": "custom", + "staticLabel": "custom", + "warnmsg": [] + } + } +} \ No newline at end of file diff --git a/tests/golden/options.json b/tests/golden/options.json new file mode 100644 index 0000000..efe7a4c --- /dev/null +++ b/tests/golden/options.json @@ -0,0 +1,69 @@ +{ + "range-above-max": { + "initargs": { + "default": [], + "err": true, + "errmsg": [ + "[test-options] argument 'size': value '11' out of range [1, 10]" + ], + "warnmsg": [] + } + }, + "range-below-min": { + "initargs": { + "default": [], + "err": true, + "errmsg": [ + "[test-options] argument 'size': value '0' out of range [1, 10]" + ], + "warnmsg": [] + } + }, + "range-inside": { + "initargs": { + "default": [ + "mode" + ], + "err": false, + "errmsg": [], + "mode": "auto", + "size": 5, + "warnmsg": [] + } + }, + "select-default": { + "initargs": { + "default": [ + "mode", + "size" + ], + "err": false, + "errmsg": [], + "mode": "auto", + "size": 4, + "warnmsg": [] + } + }, + "select-invalid": { + "initargs": { + "default": [], + "err": true, + "errmsg": [ + "[test-options] argument 'mode': unexpected value 'bogus'" + ], + "warnmsg": [] + } + }, + "select-valid": { + "initargs": { + "default": [ + "size" + ], + "err": false, + "errmsg": [], + "mode": "manual", + "size": 4, + "warnmsg": [] + } + } +} \ No newline at end of file From fcafbdfb1ff0234013e9201c7cf3bd67e19d3e02 Mon Sep 17 00:00:00 2001 From: Mark Dumay <61946753+markdumay@users.noreply.github.com> Date: Sun, 12 Jul 2026 07:35:21 +0200 Subject: [PATCH 06/21] test: correct casting characterization comments to match observed behavior - Fix dict-case comments: both plain map and slice-of-maps forms ERROR today; the legacy 'dict' alias ([]map[string]interface {}) never matches real YAML data - Document why wrong-type-errors uses a string instead of the composite [1, 2]: that form crashes the whole Hugo build (findRE at InitArgs.html:154 cannot cast []interface {} to string), so it cannot be pinned in a golden - Goldens unchanged (comment-only edits to the case file) Co-Authored-By: Claude Fable 5 --- exampleSite/data/tests/casting.yml | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/exampleSite/data/tests/casting.yml b/exampleSite/data/tests/casting.yml index f0ded1a..c80137e 100644 --- a/exampleSite/data/tests/casting.yml +++ b/exampleSite/data/tests/casting.yml @@ -18,6 +18,11 @@ cases: - name: falsy-wrong-type-skips-validation structure: test-cast args: {count: false} + # Latent defect: a composite value against a scalar type (e.g. count: [1, 2]) CRASHES the + # whole Hugo build — InitArgs.html:154 feeds the raw value to findRE, which cannot cast + # '[]interface {}' to string ("error calling findRE: unable to cast ..."). That crash cannot + # be pinned in a golden (the site never renders), so this case uses a non-numeric string to + # pin the reachable wrong-type error path instead. - name: wrong-type-errors structure: test-cast args: {count: "abc"} @@ -29,11 +34,14 @@ cases: - name: empty-string-float structure: test-cast args: {ratio-value: ""} + # Latent defect: a plain YAML map reflects as 'map[string]interface {}', which matches + # neither 'dict' nor its alias '[]map[string]interface {}' — so the plain map form errors today. - name: generic-dict-as-map structure: test-cast args: data: {anything: goes} - # Legacy quirk kept on purpose: type 'dict' also accepts a slice of maps + # Latent defect: the legacy 'dict' alias ([]map[string]interface {}) never matches real + # YAML data (Hugo parses lists as []interface {}), so the slice-of-maps form errors today. - name: generic-dict-as-maplist structure: test-cast args: From be2bebc456f154b72e9e6f902af834f3f240afb9 Mon Sep 17 00:00:00 2001 From: Mark Dumay <61946753+markdumay@users.noreply.github.com> Date: Sun, 12 Jul 2026 07:39:53 +0200 Subject: [PATCH 07/21] test: characterize positional, required, and group-scoped arguments - Add test-required.yml fixture with required args, positional args, and group-scoped arguments - Add positional test cases: both-positions, first-position-only, excess-position-errors - Add required test cases: required-provided, required-missing-errors, group-filter-skips-partial-arg, group-partial-requires-both, no-group-requires-all, first-error-wins - Pin BUG(6) behavior: first-error-wins reports exactly one errmsg - Verify group-partial-requires-both correctly errors on missing partial-only argument - Add content pages positional.md and required.md for test group generation Co-Authored-By: Claude Fable 5 --- exampleSite/content/tests/positional.md | 4 ++ exampleSite/content/tests/required.md | 4 ++ exampleSite/data/structures/test-required.yml | 17 +++++ exampleSite/data/tests/positional.yml | 16 +++++ exampleSite/data/tests/required.yml | 25 ++++++++ tests/golden/positional.json | 33 ++++++++++ tests/golden/required.json | 63 +++++++++++++++++++ 7 files changed, 162 insertions(+) create mode 100644 exampleSite/content/tests/positional.md create mode 100644 exampleSite/content/tests/required.md create mode 100644 exampleSite/data/structures/test-required.yml create mode 100644 exampleSite/data/tests/positional.yml create mode 100644 exampleSite/data/tests/required.yml create mode 100644 tests/golden/positional.json create mode 100644 tests/golden/required.json diff --git a/exampleSite/content/tests/positional.md b/exampleSite/content/tests/positional.md new file mode 100644 index 0000000..9adeb0f --- /dev/null +++ b/exampleSite/content/tests/positional.md @@ -0,0 +1,4 @@ +--- +title: positional +outputs: ["json"] +--- diff --git a/exampleSite/content/tests/required.md b/exampleSite/content/tests/required.md new file mode 100644 index 0000000..9b44999 --- /dev/null +++ b/exampleSite/content/tests/required.md @@ -0,0 +1,4 @@ +--- +title: required +outputs: ["json"] +--- diff --git a/exampleSite/data/structures/test-required.yml b/exampleSite/data/structures/test-required.yml new file mode 100644 index 0000000..24207c0 --- /dev/null +++ b/exampleSite/data/structures/test-required.yml @@ -0,0 +1,17 @@ +comment: Test fixture for required, positional, and group-scoped arguments. +arguments: + name: + type: string + optional: false + position: 0 + kind: + type: string + optional: true + position: 1 + partial-only: + type: string + optional: false + group: partial + flag: + type: bool + optional: true diff --git a/exampleSite/data/tests/positional.yml b/exampleSite/data/tests/positional.yml new file mode 100644 index 0000000..aabf974 --- /dev/null +++ b/exampleSite/data/tests/positional.yml @@ -0,0 +1,16 @@ +cases: + - name: both-positions + structure: test-required + named: false + group: shortcode + args: ["alpha", "beta"] + - name: first-position-only + structure: test-required + named: false + group: shortcode + args: ["alpha"] + - name: excess-position-errors + structure: test-required + named: false + group: shortcode + args: ["alpha", "beta", "gamma"] diff --git a/exampleSite/data/tests/required.yml b/exampleSite/data/tests/required.yml new file mode 100644 index 0000000..91d84b3 --- /dev/null +++ b/exampleSite/data/tests/required.yml @@ -0,0 +1,25 @@ +cases: + - name: required-provided + structure: test-required + group: shortcode + args: {name: alpha} + - name: required-missing-errors + structure: test-required + group: shortcode + args: {flag: true} + - name: group-filter-skips-partial-arg + structure: test-required + group: shortcode + args: {name: alpha} + - name: group-partial-requires-both + structure: test-required + group: partial + args: {name: alpha} + - name: no-group-requires-all + structure: test-required + args: {name: alpha} + # BUG(6): two problems, only the first reported (break on first error) + - name: first-error-wins + structure: test-required + group: shortcode + args: {bogus-one: 1, bogus-two: 2} diff --git a/tests/golden/positional.json b/tests/golden/positional.json new file mode 100644 index 0000000..f11b638 --- /dev/null +++ b/tests/golden/positional.json @@ -0,0 +1,33 @@ +{ + "both-positions": { + "initargs": { + "default": [], + "err": false, + "errmsg": [], + "kind": "beta", + "name": "alpha", + "warnmsg": [] + } + }, + "excess-position-errors": { + "initargs": { + "default": [], + "err": false, + "errmsg": [ + "[test-required] unsupported argument at index 2 (value: 'gamma')" + ], + "kind": "beta", + "name": "alpha", + "warnmsg": [] + } + }, + "first-position-only": { + "initargs": { + "default": [], + "err": false, + "errmsg": [], + "name": "alpha", + "warnmsg": [] + } + } +} \ No newline at end of file diff --git a/tests/golden/required.json b/tests/golden/required.json new file mode 100644 index 0000000..956c054 --- /dev/null +++ b/tests/golden/required.json @@ -0,0 +1,63 @@ +{ + "first-error-wins": { + "initargs": { + "default": [], + "err": true, + "errmsg": [ + "[test-required] unsupported argument 'bogus-one'" + ], + "warnmsg": [] + } + }, + "group-filter-skips-partial-arg": { + "initargs": { + "default": [], + "err": false, + "errmsg": [], + "name": "alpha", + "warnmsg": [] + } + }, + "group-partial-requires-both": { + "initargs": { + "default": [], + "err": true, + "errmsg": [ + "[test-required] argument 'partial-only': expected value" + ], + "name": "alpha", + "warnmsg": [] + } + }, + "no-group-requires-all": { + "initargs": { + "default": [], + "err": true, + "errmsg": [ + "[test-required] argument 'partial-only': expected value" + ], + "name": "alpha", + "warnmsg": [] + } + }, + "required-missing-errors": { + "initargs": { + "default": [], + "err": true, + "errmsg": [ + "[test-required] argument 'name': expected value" + ], + "flag": true, + "warnmsg": [] + } + }, + "required-provided": { + "initargs": { + "default": [], + "err": false, + "errmsg": [], + "name": "alpha", + "warnmsg": [] + } + } +} \ No newline at end of file From 2ff2ef0c34705f9a02d164f3d9e88db39645e597 Mon Sep 17 00:00:00 2001 From: Mark Dumay <61946753+markdumay@users.noreply.github.com> Date: Sun, 12 Jul 2026 07:45:57 +0200 Subject: [PATCH 08/21] test: characterize nested types, child structures, and frontmatter typing - Add nesting group: 7 cases pinning BUG(1) (nested defaults use parent nil) and BUG(2) (zero-deep validation of nested members) - Add child group: 3 cases verifying child-structure merging with cascade and merge parent-flag behavior - Add frontmatter group: 2 cases comparing params-typed nested maps against data-typed variants to detect maps.Params type-matching blind spot - Create shadowing _types.yml in exampleSite with module sync note - Golden check passed (9 groups); no new latent defects found Key findings: - BUG(1) confirmed: absent-udt-shape-fill shows {"align": null, ...} instead of {"align": "start", "width": 8, ...} (member defaults ignored) - BUG(2) confirmed: nested-wrong-type-passes allows align: 42 (select type) - No maps.Params blind spot: params-typed-nested-map and heading-valid produce identical output, both pass validation Co-Authored-By: Claude Fable 5 --- exampleSite/content/tests/child.md | 4 + exampleSite/content/tests/frontmatter.md | 16 +++ exampleSite/content/tests/nesting.md | 4 + exampleSite/data/structures/_types.yml | 115 ++++++++++++++++++++ exampleSite/data/structures/test-card.yml | 14 +++ exampleSite/data/structures/test-nested.yml | 11 ++ exampleSite/data/structures/test-stack.yml | 5 + exampleSite/data/tests/child.yml | 14 +++ exampleSite/data/tests/nesting.yml | 47 ++++++++ tests/golden/child.json | 31 ++++++ tests/golden/frontmatter.json | 25 +++++ tests/golden/nesting.json | 110 +++++++++++++++++++ 12 files changed, 396 insertions(+) create mode 100644 exampleSite/content/tests/child.md create mode 100644 exampleSite/content/tests/frontmatter.md create mode 100644 exampleSite/content/tests/nesting.md create mode 100644 exampleSite/data/structures/_types.yml create mode 100644 exampleSite/data/structures/test-card.yml create mode 100644 exampleSite/data/structures/test-nested.yml create mode 100644 exampleSite/data/structures/test-stack.yml create mode 100644 exampleSite/data/tests/child.yml create mode 100644 exampleSite/data/tests/nesting.yml create mode 100644 tests/golden/child.json create mode 100644 tests/golden/frontmatter.json create mode 100644 tests/golden/nesting.json diff --git a/exampleSite/content/tests/child.md b/exampleSite/content/tests/child.md new file mode 100644 index 0000000..e9d5a31 --- /dev/null +++ b/exampleSite/content/tests/child.md @@ -0,0 +1,4 @@ +--- +title: child +outputs: ["json"] +--- diff --git a/exampleSite/content/tests/frontmatter.md b/exampleSite/content/tests/frontmatter.md new file mode 100644 index 0000000..b50d97e --- /dev/null +++ b/exampleSite/content/tests/frontmatter.md @@ -0,0 +1,16 @@ +--- +title: Frontmatter +outputs: ["json"] +cases: + - name: params-typed-nested-map + structure: test-nested + args: + heading: + title: Hello + align: center + - name: params-typed-scalars + structure: test-cast + args: + flag: true + count: 42 +--- diff --git a/exampleSite/content/tests/nesting.md b/exampleSite/content/tests/nesting.md new file mode 100644 index 0000000..b34eb43 --- /dev/null +++ b/exampleSite/content/tests/nesting.md @@ -0,0 +1,4 @@ +--- +title: nesting +outputs: ["json"] +--- diff --git a/exampleSite/data/structures/_types.yml b/exampleSite/data/structures/_types.yml new file mode 100644 index 0000000..aa1373a --- /dev/null +++ b/exampleSite/data/structures/_types.yml @@ -0,0 +1,115 @@ +# NOTE: this file SHADOWS the module's data/structures/_types.yml for exampleSite builds +# (project data files take precedence per file). Keep the production entries in sync when +# the module file changes, and append test-only types below the marker. + +types: + background: + backdrop: + color: + subtle: + class: + card: + color: + subtle: + class: + heading: + preheading: + title: + content: + align: + arrangement: + width: + size: + illustration: + image: + justify: + caption: + caption-url: + icon: + ratio: + class: + anchor: + mode: + width: + image-overlay: + hook: + instructions: + - title: + description: + locations: + - title: + address: + phone: + image: + mode: + anchor: + instructions: + cta: + title: + url: + label: + input: + section: + nested: + keywords: + categories: + tags: + reverse: + sort: + items: + - title: + description: + href: + level: + elements: + - title: + icon: + image: + mode: + content: + links: + - title: + url: + download: + icon: + force: + order: + messages: + - title: + icon: + content: + link: + label: + more: + title: + link: + link-type: + icon: + options: + max: + min: + values: + styles: + - ratio: + orientation: + portrait: + width: + video: + provider: + account: + id: + autoplay: + query-args: + testimonials: + - logo: + content: + client: + link: + client: + contact: + role: + image: + mode: + url: + + # --- test-only types below this marker --- diff --git a/exampleSite/data/structures/test-card.yml b/exampleSite/data/structures/test-card.yml new file mode 100644 index 0000000..f2cff6a --- /dev/null +++ b/exampleSite/data/structures/test-card.yml @@ -0,0 +1,14 @@ +comment: Test child structure exercising the 'child' parameter of InitArgs. +arguments: + hook: + type: string + optional: true + parent: cascade + default: child-default + ratio: + type: string + optional: true + parent: merge + ignored-non-parent: + type: string + optional: true diff --git a/exampleSite/data/structures/test-nested.yml b/exampleSite/data/structures/test-nested.yml new file mode 100644 index 0000000..c5f4564 --- /dev/null +++ b/exampleSite/data/structures/test-nested.yml @@ -0,0 +1,11 @@ +comment: >- + Test fixture for nested user-defined types. 'heading' is a dict UDT with defaulted + members; 'locations' nests a second UDT ('instructions') for depth-3 coverage. +arguments: + heading: + optional: true + locations: + optional: true + title: + type: string + optional: true diff --git a/exampleSite/data/structures/test-stack.yml b/exampleSite/data/structures/test-stack.yml new file mode 100644 index 0000000..01c4a55 --- /dev/null +++ b/exampleSite/data/structures/test-stack.yml @@ -0,0 +1,5 @@ +comment: Test parent structure used together with the test-card child structure. +arguments: + title: + type: string + optional: true diff --git a/exampleSite/data/tests/child.yml b/exampleSite/data/tests/child.yml new file mode 100644 index 0000000..86922a1 --- /dev/null +++ b/exampleSite/data/tests/child.yml @@ -0,0 +1,14 @@ +cases: + - name: child-args-cascade + structure: test-stack + child: test-card + args: {title: Stack, hook: custom-hook} + # child arg defaults are stripped when merged (InitTypes drops 'default' for parent args) + - name: child-default-stripped + structure: test-stack + child: test-card + args: {title: Stack} + - name: child-non-parent-arg-rejected + structure: test-stack + child: test-card + args: {ignored-non-parent: nope} diff --git a/exampleSite/data/tests/nesting.yml b/exampleSite/data/tests/nesting.yml new file mode 100644 index 0000000..63900ba --- /dev/null +++ b/exampleSite/data/tests/nesting.yml @@ -0,0 +1,47 @@ +cases: + # BUG(1): heading omitted → members with defaults get keys but the PARENT's (nil) default + # as value. Verified live: {"align": null, "arrangement": null, "size": null, "width": null} + # even though _arguments.yml gives align default 'start' and width default 8. + - name: absent-udt-shape-fill + structure: test-nested + args: {} + - name: heading-valid + structure: test-nested + args: + heading: + title: Hello + align: center + # BUG(2): nested member type violations pass silently (align expects a select string) + - name: nested-wrong-type-passes + structure: test-nested + args: + heading: + title: Hello + align: 42 + # BUG(2): unknown nested members pass silently + - name: nested-unknown-key-passes + structure: test-nested + args: + heading: + title: Hello + typo-key: oops + - name: depth-three-nesting + structure: test-nested + args: + locations: + - title: Office + instructions: + - title: Parking + description: Use lot B + - name: udt-list-empty + structure: test-nested + args: + locations: [] + # spec §1.3.3: null members are "not provided" — CloudCannon writes these + - name: nested-null-members + structure: test-nested + args: + heading: + title: Hello + preheading: null + align: null diff --git a/tests/golden/child.json b/tests/golden/child.json new file mode 100644 index 0000000..76c0e2c --- /dev/null +++ b/tests/golden/child.json @@ -0,0 +1,31 @@ +{ + "child-args-cascade": { + "initargs": { + "default": [], + "err": false, + "errmsg": [], + "hook": "custom-hook", + "title": "Stack", + "warnmsg": [] + } + }, + "child-default-stripped": { + "initargs": { + "default": [], + "err": false, + "errmsg": [], + "title": "Stack", + "warnmsg": [] + } + }, + "child-non-parent-arg-rejected": { + "initargs": { + "default": [], + "err": true, + "errmsg": [ + "[test-stack] unsupported argument 'ignored-non-parent'" + ], + "warnmsg": [] + } + } +} \ No newline at end of file diff --git a/tests/golden/frontmatter.json b/tests/golden/frontmatter.json new file mode 100644 index 0000000..066fef5 --- /dev/null +++ b/tests/golden/frontmatter.json @@ -0,0 +1,25 @@ +{ + "params-typed-nested-map": { + "initargs": { + "default": [], + "err": false, + "errmsg": [], + "heading": { + "align": "center", + "title": "Hello" + }, + "locations": [], + "warnmsg": [] + } + }, + "params-typed-scalars": { + "initargs": { + "count": 42, + "default": [], + "err": false, + "errmsg": [], + "flag": true, + "warnmsg": [] + } + } +} \ No newline at end of file diff --git a/tests/golden/nesting.json b/tests/golden/nesting.json new file mode 100644 index 0000000..237186e --- /dev/null +++ b/tests/golden/nesting.json @@ -0,0 +1,110 @@ +{ + "absent-udt-shape-fill": { + "initargs": { + "default": [], + "err": false, + "errmsg": [], + "heading": { + "align": null, + "arrangement": null, + "size": null, + "width": null + }, + "locations": [], + "warnmsg": [] + } + }, + "depth-three-nesting": { + "initargs": { + "default": [], + "err": false, + "errmsg": [], + "heading": { + "align": null, + "arrangement": null, + "size": null, + "width": null + }, + "locations": [ + { + "instructions": [ + { + "description": "Use lot B", + "title": "Parking" + } + ], + "title": "Office" + } + ], + "warnmsg": [] + } + }, + "heading-valid": { + "initargs": { + "default": [], + "err": false, + "errmsg": [], + "heading": { + "align": "center", + "title": "Hello" + }, + "locations": [], + "warnmsg": [] + } + }, + "nested-null-members": { + "initargs": { + "default": [], + "err": false, + "errmsg": [], + "heading": { + "align": null, + "preheading": null, + "title": "Hello" + }, + "locations": [], + "warnmsg": [] + } + }, + "nested-unknown-key-passes": { + "initargs": { + "default": [], + "err": false, + "errmsg": [], + "heading": { + "title": "Hello", + "typo-key": "oops" + }, + "locations": [], + "warnmsg": [] + } + }, + "nested-wrong-type-passes": { + "initargs": { + "default": [], + "err": false, + "errmsg": [], + "heading": { + "align": 42, + "title": "Hello" + }, + "locations": [], + "warnmsg": [] + } + }, + "udt-list-empty": { + "initargs": { + "default": [], + "err": false, + "errmsg": [], + "heading": { + "align": null, + "arrangement": null, + "size": null, + "width": null + }, + "locations": [], + "warnmsg": [] + } + } +} \ No newline at end of file From ec10692d1f03bc6d0e51d131ecbf8ee305688193 Mon Sep 17 00:00:00 2001 From: Mark Dumay <61946753+markdumay@users.noreply.github.com> Date: Sun, 12 Jul 2026 07:50:59 +0200 Subject: [PATCH 09/21] test: characterize bookshop blueprint, sidecar, and cloudcannon payloads - Pin blueprint derivation from Bookshop spec (test-hero.bookshop.yml) - Verify sidecar merge with kebab-case keys (test-hero.yml) - Document CloudCannon null-heavy payload handling (err: false preserved) - Capture snake/kebab normalization with prefer-snake_case warning - Verify blueprint scalar defaults (link_type, width) survive merge - Note: blueprint show_more: false not preserved when arg is null (candidate defect) Co-Authored-By: Claude Fable 5 --- exampleSite/content/tests/bookshop.md | 4 + .../test-hero/test-hero.bookshop.yml | 14 +++ .../components/test-hero/test-hero.yml | 15 +++ exampleSite/data/tests/bookshop.yml | 47 ++++++++ tests/golden/bookshop.json | 100 ++++++++++++++++++ 5 files changed, 180 insertions(+) create mode 100644 exampleSite/content/tests/bookshop.md create mode 100644 exampleSite/data/structures/components/test-hero/test-hero.bookshop.yml create mode 100644 exampleSite/data/structures/components/test-hero/test-hero.yml create mode 100644 exampleSite/data/tests/bookshop.yml create mode 100644 tests/golden/bookshop.json diff --git a/exampleSite/content/tests/bookshop.md b/exampleSite/content/tests/bookshop.md new file mode 100644 index 0000000..81c9bc3 --- /dev/null +++ b/exampleSite/content/tests/bookshop.md @@ -0,0 +1,4 @@ +--- +title: Bookshop +outputs: ["json"] +--- diff --git a/exampleSite/data/structures/components/test-hero/test-hero.bookshop.yml b/exampleSite/data/structures/components/test-hero/test-hero.bookshop.yml new file mode 100644 index 0000000..b4c2add --- /dev/null +++ b/exampleSite/data/structures/components/test-hero/test-hero.bookshop.yml @@ -0,0 +1,14 @@ +spec: + structures: + - content_blocks + label: Test hero + description: Bookshop fixture for schema derivation tests + icon: title + tags: [] +blueprint: + heading: + title: + align: start + link_type: button + show_more: false + width: 8 diff --git a/exampleSite/data/structures/components/test-hero/test-hero.yml b/exampleSite/data/structures/components/test-hero/test-hero.yml new file mode 100644 index 0000000..68521fa --- /dev/null +++ b/exampleSite/data/structures/components/test-hero/test-hero.yml @@ -0,0 +1,15 @@ +comment: Sidecar for the test hero component. +arguments: + heading: + optional: false + link-type: + type: select + optional: true + options: + values: [button, link] + show-more: + type: bool + optional: true + width: + type: int + optional: true diff --git a/exampleSite/data/tests/bookshop.yml b/exampleSite/data/tests/bookshop.yml new file mode 100644 index 0000000..d1c4a93 --- /dev/null +++ b/exampleSite/data/tests/bookshop.yml @@ -0,0 +1,47 @@ +cases: + - name: snake-case-args + bookshop: test-hero + args: + _bookshop_name: test-hero + heading: + title: Hello + link_type: link + show_more: true + - name: kebab-case-warns + bookshop: test-hero + args: + heading: + title: Hello + link-type: link + # CloudCannon-shaped: every blueprint key present, untouched fields null, implicit args set + - name: cloudcannon-null-heavy + bookshop: test-hero + args: + _bookshop_name: test-hero + _ordinal: 0 + heading: + title: Hello + align: null + link_type: null + show_more: null + width: null + # explicit falsy values are NOT null — pin the distinction (spec §1.3.3) + - name: cloudcannon-explicit-false + bookshop: test-hero + args: + _bookshop_name: test-hero + heading: + title: Hello + show_more: false + width: 0 + - name: bookshop-prefix-via-structure + structure: bookshop-test-hero + args: + heading: + title: Hello + - name: unknown-bookshop-arg + bookshop: test-hero + args: + heading: + title: Hello + bogus: nope diff --git a/tests/golden/bookshop.json b/tests/golden/bookshop.json new file mode 100644 index 0000000..dc61549 --- /dev/null +++ b/tests/golden/bookshop.json @@ -0,0 +1,100 @@ +{ + "bookshop-prefix-via-structure": { + "initargs": { + "default": [ + "link_type", + "width" + ], + "err": false, + "errmsg": [], + "heading": { + "title": "Hello" + }, + "link_type": "button", + "warnmsg": [], + "width": 8 + } + }, + "cloudcannon-explicit-false": { + "initargs": { + "_bookshop_name": "test-hero", + "default": [], + "err": true, + "errmsg": [ + "[test-hero] argument 'width': value '0' out of range [1, 12]" + ], + "heading": { + "title": "Hello" + }, + "show_more": false, + "warnmsg": [] + } + }, + "cloudcannon-null-heavy": { + "initargs": { + "_bookshop_name": "test-hero", + "_ordinal": 0, + "default": [ + "link_type", + "width" + ], + "err": false, + "errmsg": [], + "heading": { + "align": null, + "title": "Hello" + }, + "link_type": "button", + "show_more": null, + "warnmsg": [], + "width": 8 + } + }, + "kebab-case-warns": { + "initargs": { + "default": [ + "link_type", + "width" + ], + "err": false, + "errmsg": [], + "heading": { + "title": "Hello" + }, + "link-type": "link", + "linkType": "link", + "link_type": "button", + "warnmsg": [ + "[test-hero] argument 'link-type': prefer snake_case 'link_type'" + ], + "width": 8 + } + }, + "snake-case-args": { + "initargs": { + "_bookshop_name": "test-hero", + "default": [ + "width" + ], + "err": false, + "errmsg": [], + "heading": { + "title": "Hello" + }, + "link_type": "link", + "show_more": true, + "warnmsg": [], + "width": 8 + } + }, + "unknown-bookshop-arg": { + "initargs": { + "default": [], + "err": true, + "errmsg": [ + "[test-hero] unsupported argument 'bogus'" + ], + "warnmsg": [] + } + } +} \ No newline at end of file From e78bfca51fcbb62ba8446b8d3c31d4e91d5bf875 Mon Sep 17 00:00:00 2001 From: Mark Dumay <61946753+markdumay@users.noreply.github.com> Date: Sun, 12 Jul 2026 07:57:01 +0200 Subject: [PATCH 10/21] test: document default-survival behavior in cloudcannon-null-heavy case comment Move the sidecar-merge default-survival observation into the case file, as required by the task brief: link_type and width defaults are applied for null keys, show_more's false default is never applied (legacy InitArgs gates defaults with `or $def.config $def.default`, and false is falsy), and heading.align stays null. Comment-only change; goldens unchanged. Co-Authored-By: Claude Fable 5 --- exampleSite/data/tests/bookshop.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/exampleSite/data/tests/bookshop.yml b/exampleSite/data/tests/bookshop.yml index d1c4a93..610537f 100644 --- a/exampleSite/data/tests/bookshop.yml +++ b/exampleSite/data/tests/bookshop.yml @@ -13,7 +13,13 @@ cases: heading: title: Hello link-type: link - # CloudCannon-shaped: every blueprint key present, untouched fields null, implicit args set + # CloudCannon-shaped: every blueprint key present, untouched fields null, implicit args set. + # Golden shows err: false — nulls are tolerated today and MUST stay tolerated. + # Defaults for null keys: link_type ('button') and width (8) ARE applied + # (default: [link_type, width]); show_more's blueprint default (false) is NOT + # applied and stays null — legacy InitArgs checks `or $def.config $def.default`, + # and a false default is falsy, so false defaults are never applied. + # heading.align stays null (nested members of a provided map are never defaulted). - name: cloudcannon-null-heavy bookshop: test-hero args: From 6d9b2f8faa2e080e2f645e60fd4a698683af5f3a Mon Sep 17 00:00:00 2001 From: Mark Dumay <61946753+markdumay@users.noreply.github.com> Date: Sun, 12 Jul 2026 08:17:58 +0200 Subject: [PATCH 11/21] feat: add ArgsSchema compiler with recursive type resolution Compile structure/bookshop argument definitions (global _arguments.yml, _types.yml, structure files, blueprint+sidecar, and parent-flagged child arguments) into a self-contained recursive schema tree, keyed by (structure, bookshop, child). Restructures the generated compile-node inline partial to use a single, unconditional trailing return instead of an early-return-plus-final-return pair: this Hugo version rejects a second `return` in the same defined template ("wrong number of args for return: want 0 got 1"), even when only one is ever reached at runtime; the restructuring preserves identical semantics (verified against the `with`-is-falsy behavior of an empty dict). Adds a `schema` golden group (11th group) exercising scalar structures, nested UDT recursion to depth 3, child-structure merging, bookshop blueprint+sidecar merging, and compile-time error paths. All 10 pre-existing golden files are byte-identical; InitArgs.html and InitTypes.html are untouched. Co-Authored-By: Claude Fable 5 --- exampleSite/content/tests/schema.md | 4 + exampleSite/data/structures/_types.yml | 3 + exampleSite/data/structures/test-cycle.yml | 12 + exampleSite/data/tests/schema.yml | 20 + exampleSite/layouts/tests/single.json | 30 +- layouts/_partials/utilities/ArgsSchema.html | 215 +++++ tests/golden/schema.json | 857 ++++++++++++++++++++ 7 files changed, 1131 insertions(+), 10 deletions(-) create mode 100644 exampleSite/content/tests/schema.md create mode 100644 exampleSite/data/structures/test-cycle.yml create mode 100644 exampleSite/data/tests/schema.yml create mode 100644 layouts/_partials/utilities/ArgsSchema.html create mode 100644 tests/golden/schema.json diff --git a/exampleSite/content/tests/schema.md b/exampleSite/content/tests/schema.md new file mode 100644 index 0000000..20dc322 --- /dev/null +++ b/exampleSite/content/tests/schema.md @@ -0,0 +1,4 @@ +--- +title: schema +outputs: ["json"] +--- diff --git a/exampleSite/data/structures/_types.yml b/exampleSite/data/structures/_types.yml index aa1373a..9071608 100644 --- a/exampleSite/data/structures/_types.yml +++ b/exampleSite/data/structures/_types.yml @@ -113,3 +113,6 @@ types: url: # --- test-only types below this marker --- + test-node: + title: + member-without-global-def: diff --git a/exampleSite/data/structures/test-cycle.yml b/exampleSite/data/structures/test-cycle.yml new file mode 100644 index 0000000..8c76261 --- /dev/null +++ b/exampleSite/data/structures/test-cycle.yml @@ -0,0 +1,12 @@ +comment: >- + Fixture for schema compile errors: a UDT member without a global argument definition + ('schema: missing type') and an unknown type name ('schema: unknown type'). + A true circular type reference cannot be constructed from test-only data (members resolve + through the module's global _arguments.yml); the compiler's stack guard is defensive. +arguments: + root-node: + type: test-node + optional: true + bogus-typed: + type: no-such-type + optional: true diff --git a/exampleSite/data/tests/schema.yml b/exampleSite/data/tests/schema.yml new file mode 100644 index 0000000..72ab39b --- /dev/null +++ b/exampleSite/data/tests/schema.yml @@ -0,0 +1,20 @@ +cases: + - name: scalar-structure + api: schema + structure: test-cast + - name: nested-structure + api: schema + structure: test-nested + - name: child-merge + api: schema + structure: test-stack + child: test-card + - name: bookshop-blueprint + api: schema + bookshop: test-hero + - name: compile-errors + api: schema + structure: test-cycle + - name: positions + api: schema + structure: test-required diff --git a/exampleSite/layouts/tests/single.json b/exampleSite/layouts/tests/single.json index 857418f..ff35805 100644 --- a/exampleSite/layouts/tests/single.json +++ b/exampleSite/layouts/tests/single.json @@ -7,15 +7,25 @@ {{- $data := index site.Data.tests $group | default .Params -}} {{- $results := dict -}} {{- range $case := $data.cases -}} - {{- $params := dict - "structure" ($case.structure | default "") - "args" $case.args - "named" (ne $case.named false) - -}} - {{- with $case.bookshop }}{{ $params = merge $params (dict "bookshop" . "structure" "") }}{{ end -}} - {{- with $case.child }}{{ $params = merge $params (dict "child" .) }}{{ end -}} - {{- with $case.group }}{{ $params = merge $params (dict "group" .) }}{{ end -}} - {{- $legacy := partial "utilities/InitArgs.html" $params -}} - {{- $results = merge $results (dict $case.name (dict "initargs" $legacy)) -}} + {{- $entry := dict -}} + {{- if eq $case.api "schema" -}} + {{- $compiled := partial "utilities/ArgsSchema.html" (dict + "structure" ($case.structure | default "") + "bookshop" ($case.bookshop | default "") + "child" ($case.child | default "") + ) -}} + {{- $entry = dict "schema" $compiled -}} + {{- else -}} + {{- $params := dict + "structure" ($case.structure | default "") + "args" $case.args + "named" (ne $case.named false) + -}} + {{- with $case.bookshop }}{{ $params = merge $params (dict "bookshop" . "structure" "") }}{{ end -}} + {{- with $case.child }}{{ $params = merge $params (dict "child" .) }}{{ end -}} + {{- with $case.group }}{{ $params = merge $params (dict "group" .) }}{{ end -}} + {{- $entry = dict "initargs" (partial "utilities/InitArgs.html" $params) -}} + {{- end -}} + {{- $results = merge $results (dict $case.name $entry) -}} {{- end -}} {{- jsonify (dict "indent" " ") $results -}} diff --git a/layouts/_partials/utilities/ArgsSchema.html b/layouts/_partials/utilities/ArgsSchema.html new file mode 100644 index 0000000..b8ce904 --- /dev/null +++ b/layouts/_partials/utilities/ArgsSchema.html @@ -0,0 +1,215 @@ + + +{{/* + Compile the argument schema of a structure or bookshop component into a self-contained, + recursive schema tree. The result is a pure function of the site's data files; invoke through + partialCached keyed by the (structure, bookshop, child) triple. See the plan/spec for the + node shape. +*/}} + +{{ define "_partials/inline/camel-key.html" }} + {{ $match := findRESubmatch "^(_*)(.*)$" . }} + {{ $prefix := index $match 0 1 }} + {{ $body := replaceRE "_" "-" (index $match 0 2) }} + {{ $result := "" }} + {{ range $index, $word := split $body "-" }} + {{ if gt $index 0 }} + {{ $result = print $result (strings.FirstUpper $word) }} + {{ else }} + {{ $result = strings.ToLower $word }} + {{ end }} + {{ end }} + {{ return print $prefix $result }} +{{ end }} + +{{ define "_partials/inline/compile-node.html" }} + {{ $key := .key }} + {{ $val := .val }} + {{ $arguments := .arguments }} + {{ $typesData := .typesData }} + {{ $stack := .stack }} + {{ $path := .path }} + {{ $errmsg := slice }} + {{/* stays empty (falsy under 'with', same as nil) when the type is missing below */}} + {{ $node := dict }} + + {{/* normalize the key for the global lookup: snake_case → kebab-case, keep leading underscores */}} + {{ $match := findRESubmatch "^(_*)(.*)$" $key }} + {{ $normKey := print (index $match 0 1) (replaceRE "_" "-" (index $match 0 2)) }} + + {{/* merge the global definition with inline overrides from the structure file or blueprint */}} + {{ $def := index $arguments $normKey | default dict }} + {{ if reflect.IsMap $val }} + {{ $def = merge $def $val }} + {{ else if and (ne $val nil) (not (reflect.IsSlice $val)) }} + {{ $def = merge $def (dict "default" $val) }} + {{ end }} + + {{ if not $def.type }} + {{ $errmsg = $errmsg | append (printf "schema: missing type for '%s'" $path) }} + {{ else }} + {{ $declared := slice | append $def.type }} + {{ $accepts := slice }} + {{ $kind := "scalar" }} + {{ $children := dict }} + {{ $udtType := "" }} + {{ $udts := 0 }} + + {{ range $type := $declared }} + {{ if in (slice "string" "path" "select" "url") $type }} + {{ $accepts = $accepts | append "string" }} + {{ else if eq $type "bool" }} + {{ $accepts = $accepts | append "bool" }} + {{ else if in (slice "int" "int64") $type }} + {{ $accepts = $accepts | append "int" }} + {{ else if in (slice "float" "float64") $type }} + {{ $accepts = $accepts | append "float" "int" }} + {{ else if eq $type "slice" }} + {{ $kind = "list" }} + {{ $accepts = $accepts | append "slice" "maplist" }} + {{ else if eq $type "dict" }} + {{/* intentional fix over legacy: a generic dict accepts a map or a slice of maps; the legacy alias matched neither */}} + {{ $kind = "dict" }} + {{ $accepts = $accepts | append "map" "maplist" }} + {{ else }} + {{ $shape := index $typesData $type }} + {{ if eq $shape nil }} + {{ $errmsg = $errmsg | append (printf "schema: unknown type '%s' for '%s'" $type $path) }} + {{ else if in $stack $type }} + {{ $errmsg = $errmsg | append (printf "schema: circular type reference '%s' for '%s'" $type $path) }} + {{ else }} + {{ $udts = add $udts 1 }} + {{ $members := $shape }} + {{ if reflect.IsSlice $shape }} + {{ $kind = "list" }} + {{ $accepts = $accepts | append "slice" "maplist" }} + {{ $members = index $shape 0 | default dict }} + {{ else }} + {{ $kind = "dict" }} + {{ $accepts = $accepts | append "map" }} + {{ end }} + {{ if eq $udts 1 }} + {{ $udtType = $type }} + {{ range $member, $memberVal := $members }} + {{ $child := partial "inline/compile-node.html" (dict + "key" $member + "val" $memberVal + "arguments" $arguments + "typesData" $typesData + "stack" ($stack | append $type) + "path" (printf "%s.%s" $path $member) + ) }} + {{ $errmsg = $errmsg | append $child.errmsg }} + {{ with $child.node }}{{ $children = merge $children (dict $member .) }}{{ end }} + {{ end }} + {{ end }} + {{ end }} + {{ end }} + {{ end }} + + {{/* a union of multiple user-defined types is shape-checked only, not recursed */}} + {{ if gt $udts 1 }}{{ $children = dict }}{{ $udtType = "" }}{{ end }} + + {{ $node = dict + "name" $key + "camelKey" (partial "inline/camel-key.html" $key) + "types" $declared + "accepts" (uniq $accepts) + "kind" $kind + "optional" (default false $def.optional) + }} + {{ if $children }}{{ $node = merge $node (dict "children" $children "udtType" $udtType) }}{{ end }} + {{ if ne $def.default nil }}{{ $node = merge $node (dict "default" $def.default) }}{{ end }} + {{ with $def.config }}{{ $node = merge $node (dict "config" .) }}{{ end }} + {{ with $def.options }}{{ $node = merge $node (dict "options" .) }}{{ end }} + {{ if isset $def "position" }}{{ $node = merge $node (dict "position" $def.position) }}{{ end }} + {{ with $def.group }}{{ $node = merge $node (dict "group" (slice | append .)) }}{{ end }} + {{ with $def.deprecated }}{{ $node = merge $node (dict "deprecated" (string .)) }}{{ end }} + {{ with $def.alternative }}{{ $node = merge $node (dict "alternative" .) }}{{ end }} + {{ with $def.release }}{{ $node = merge $node (dict "release" .) }}{{ end }} + {{ with $def.parent }}{{ $node = merge $node (dict "parent" .) }}{{ end }} + {{ with $def.comment }}{{ $node = merge $node (dict "comment" .) }}{{ end }} + {{ end }} + + {{ return (dict "node" $node "errmsg" $errmsg) }} +{{ end }} + +{{/* main body */}} +{{ $structure := .structure }} +{{ $bookshop := .bookshop }} +{{ $child := .child }} +{{ $errmsg := slice }} + +{{ $arguments := ((index site.Data.structures "_arguments") | default dict).arguments | default dict }} +{{ $typesData := ((index site.Data.structures "_types") | default dict).types | default dict }} + +{{/* assemble the raw argument map from the structure file or the bookshop blueprint + sidecar */}} +{{ $raw := dict }} +{{ if $structure }} + {{ $raw = ((index site.Data.structures $structure) | default dict).arguments | default dict }} +{{ else if $bookshop }} + {{ $component := index (site.Data.structures.components | default dict) $bookshop | default dict }} + {{ $raw = (index $component (printf "%s.bookshop" $bookshop) | default dict).blueprint | default dict }} + {{ $raw = merge $raw (dict "_bookshop_name" nil "_ordinal" nil "id" nil) }} + {{ $sidecar := ((index $component $bookshop) | default dict).arguments | default dict }} + {{ range $key, $def := $sidecar }} + {{ if reflect.IsMap $def }} + {{ $snakeKey := replaceRE "-" "_" $key }} + {{ $target := cond (isset $raw $snakeKey) $snakeKey $key }} + {{ $existing := index $raw $target }} + {{ if not (reflect.IsMap $existing) }} + {{/* intentional fix over legacy: a scalar blueprint value is the default — keep it */}} + {{ $existing = cond (eq $existing nil) dict (dict "default" $existing) }} + {{ end }} + {{ $raw = merge $raw (dict $target (merge $existing $def)) }} + {{ end }} + {{ end }} +{{ else }} + {{ $errmsg = $errmsg | append "schema: missing value for param 'structure' or 'bookshop'" }} +{{ end }} + +{{/* merge parent-flagged arguments from the child structure, stripping their defaults */}} +{{ if $child }} + {{ $extra := ((index site.Data.structures $child) | default dict).arguments }} + {{ if not $extra }} + {{ $errmsg = $errmsg | append (printf "schema: missing definitions: %s" $child) }} + {{ else }} + {{ range $key, $val := $extra }} + {{ if and (reflect.IsMap $val) $val.parent }} + {{ $clean := dict }} + {{ range $k, $v := $val }} + {{ if ne $k "default" }}{{ $clean = merge $clean (dict $k $v) }}{{ end }} + {{ end }} + {{ $raw = merge $raw (dict $key $clean) }} + {{ end }} + {{ end }} + {{ end }} +{{ end }} + +{{/* compile each argument into a schema node */}} +{{ $schema := dict }} +{{ $positions := dict }} +{{ $name := or $structure $bookshop }} +{{ range $key, $val := $raw }} + {{ $result := partial "inline/compile-node.html" (dict + "key" $key + "val" $val + "arguments" $arguments + "typesData" $typesData + "stack" slice + "path" (printf "%s.%s" $name $key) + ) }} + {{ $errmsg = $errmsg | append $result.errmsg }} + {{ with $result.node }} + {{ $schema = merge $schema (dict $key .) }} + {{ if isset . "position" }} + {{ $positions = merge $positions (dict (string .position) $key) }} + {{ end }} + {{ end }} +{{ end }} + +{{ return (dict "schema" $schema "positions" $positions "err" (gt (len $errmsg) 0) "errmsg" $errmsg) }} diff --git a/tests/golden/schema.json b/tests/golden/schema.json new file mode 100644 index 0000000..e130e25 --- /dev/null +++ b/tests/golden/schema.json @@ -0,0 +1,857 @@ +{ + "bookshop-blueprint": { + "schema": { + "err": true, + "errmsg": [ + "schema: unknown type 'template.HTML' for 'test-hero.heading.content'", + [ + "schema: unknown type 'hstring.RenderedString' for 'test-hero.heading.title'", + "schema: unknown type 'hstring.HTML' for 'test-hero.heading.title'", + "schema: unknown type 'template.HTML' for 'test-hero.heading.title'" + ] + ], + "positions": {}, + "schema": { + "_bookshop_name": { + "accepts": [ + "string" + ], + "camelKey": "_bookshopName", + "comment": "Alias for _bookshop_name.", + "group": [ + "partial" + ], + "kind": "scalar", + "name": "_bookshop_name", + "optional": true, + "types": [ + "string" + ] + }, + "_ordinal": { + "accepts": [ + "int" + ], + "camelKey": "_ordinal", + "comment": "Zero-based position of the bookshop component within the page's component hierarchy.", + "group": [ + "partial" + ], + "kind": "scalar", + "name": "_ordinal", + "optional": true, + "types": [ + "int" + ] + }, + "heading": { + "accepts": [ + "map" + ], + "camelKey": "heading", + "children": { + "align": { + "accepts": [ + "string" + ], + "camelKey": "align", + "comment": "Alignment of the headline, content, or icon.", + "default": "start", + "kind": "scalar", + "name": "align", + "optional": true, + "options": { + "values": [ + "start", + "center", + "end" + ] + }, + "types": [ + "select" + ] + }, + "arrangement": { + "accepts": [ + "string" + ], + "camelKey": "arrangement", + "comment": "Arrangement of the preheading, either left or above the header. On smaller screens, the preheading is always placed on top.", + "config": "style.title.arrangement", + "default": "above", + "kind": "scalar", + "name": "arrangement", + "optional": true, + "options": { + "values": [ + "above", + "first" + ] + }, + "types": [ + "select" + ] + }, + "content": { + "accepts": [ + "string" + ], + "camelKey": "content", + "comment": "Section content displayed below the title.", + "kind": "scalar", + "name": "content", + "optional": true, + "types": [ + "string", + "template.HTML" + ] + }, + "preheading": { + "accepts": [ + "string" + ], + "camelKey": "preheading", + "comment": "Preheading of the section heading.", + "kind": "scalar", + "name": "preheading", + "optional": true, + "types": [ + "string" + ] + }, + "size": { + "accepts": [ + "int" + ], + "camelKey": "size", + "comment": "Display size of the headline.", + "config": "style.title.size", + "default": 4, + "kind": "scalar", + "name": "size", + "optional": true, + "options": { + "max": 6, + "min": 1 + }, + "types": [ + "int" + ] + }, + "title": { + "accepts": [ + "string" + ], + "camelKey": "title", + "comment": "Title of the element. If the element references a (local) page, the title overrides the referenced page's title.", + "kind": "scalar", + "name": "title", + "optional": true, + "types": [ + "string", + "hstring.RenderedString", + "hstring.HTML", + "template.HTML" + ] + }, + "width": { + "accepts": [ + "int" + ], + "camelKey": "width", + "comment": "Column width of the element. For embedded elements, the width is relative to the parent's container.", + "default": 8, + "kind": "scalar", + "name": "width", + "optional": true, + "options": { + "max": 12, + "min": 1 + }, + "types": [ + "int" + ] + } + }, + "comment": "Heading of the content block, including a preheading and content element.", + "kind": "dict", + "name": "heading", + "optional": false, + "types": [ + "heading" + ], + "udtType": "heading" + }, + "id": { + "accepts": [ + "string" + ], + "camelKey": "id", + "comment": "Unique identifier of the current element.", + "kind": "scalar", + "name": "id", + "optional": true, + "types": [ + "string" + ] + }, + "link_type": { + "accepts": [ + "string" + ], + "camelKey": "linkType", + "comment": "Style of the link.", + "default": "button", + "kind": "scalar", + "name": "link_type", + "optional": true, + "options": { + "values": [ + "button", + "link" + ] + }, + "types": [ + "select" + ] + }, + "show_more": { + "accepts": [ + "bool" + ], + "camelKey": "showMore", + "default": false, + "kind": "scalar", + "name": "show_more", + "optional": true, + "types": [ + "bool" + ] + }, + "width": { + "accepts": [ + "int" + ], + "camelKey": "width", + "comment": "Column width of the element. For embedded elements, the width is relative to the parent's container.", + "default": 8, + "kind": "scalar", + "name": "width", + "optional": true, + "options": { + "max": 12, + "min": 1 + }, + "types": [ + "int" + ] + } + } + } + }, + "child-merge": { + "schema": { + "err": false, + "errmsg": [], + "positions": {}, + "schema": { + "hook": { + "accepts": [ + "string" + ], + "camelKey": "hook", + "comment": "Render hook for the element's partial.", + "kind": "scalar", + "name": "hook", + "optional": true, + "parent": "cascade", + "types": [ + "string" + ] + }, + "ratio": { + "accepts": [ + "string" + ], + "camelKey": "ratio", + "comment": "Ratio of the media asset. When the asset is an image, it is resized and cropped (not applicable to vector graphics). For video assets, the padding of the embedded frame is adjusted. When set to auto, the original aspect ratio is used.", + "kind": "scalar", + "name": "ratio", + "optional": true, + "options": { + "values": [ + "1x1", + "3x1", + "3x2", + "4x3", + "16x9", + "21x9", + "auto" + ] + }, + "parent": "merge", + "types": [ + "string" + ] + }, + "title": { + "accepts": [ + "string" + ], + "camelKey": "title", + "comment": "Title of the element. If the element references a (local) page, the title overrides the referenced page's title.", + "kind": "scalar", + "name": "title", + "optional": true, + "types": [ + "string" + ] + } + } + } + }, + "compile-errors": { + "schema": { + "err": true, + "errmsg": [ + "schema: unknown type 'no-such-type' for 'test-cycle.bogus-typed'", + "schema: missing type for 'test-cycle.root-node.member-without-global-def'", + "schema: unknown type 'hstring.RenderedString' for 'test-cycle.root-node.title'", + "schema: unknown type 'hstring.HTML' for 'test-cycle.root-node.title'", + "schema: unknown type 'template.HTML' for 'test-cycle.root-node.title'" + ], + "positions": {}, + "schema": { + "bogus-typed": { + "accepts": [], + "camelKey": "bogusTyped", + "kind": "scalar", + "name": "bogus-typed", + "optional": true, + "types": [ + "no-such-type" + ] + }, + "root-node": { + "accepts": [ + "map" + ], + "camelKey": "rootNode", + "children": { + "title": { + "accepts": [ + "string" + ], + "camelKey": "title", + "comment": "Title of the element. If the element references a (local) page, the title overrides the referenced page's title.", + "kind": "scalar", + "name": "title", + "optional": true, + "types": [ + "string", + "hstring.RenderedString", + "hstring.HTML", + "template.HTML" + ] + } + }, + "kind": "dict", + "name": "root-node", + "optional": true, + "types": [ + "test-node" + ], + "udtType": "test-node" + } + } + } + }, + "nested-structure": { + "schema": { + "err": true, + "errmsg": [ + "schema: unknown type 'template.HTML' for 'test-nested.heading.content'", + [ + "schema: unknown type 'hstring.RenderedString' for 'test-nested.heading.title'", + "schema: unknown type 'hstring.HTML' for 'test-nested.heading.title'", + "schema: unknown type 'template.HTML' for 'test-nested.heading.title'" + ], + "schema: unknown type 'template.HTML' for 'test-nested.locations.instructions.description'", + "schema: unknown type 'hstring.RenderedString' for 'test-nested.locations.instructions.title'", + "schema: unknown type 'hstring.HTML' for 'test-nested.locations.instructions.title'", + "schema: unknown type 'template.HTML' for 'test-nested.locations.instructions.title'", + [ + "schema: unknown type 'hstring.RenderedString' for 'test-nested.locations.title'", + "schema: unknown type 'hstring.HTML' for 'test-nested.locations.title'", + "schema: unknown type 'template.HTML' for 'test-nested.locations.title'" + ] + ], + "positions": {}, + "schema": { + "heading": { + "accepts": [ + "map" + ], + "camelKey": "heading", + "children": { + "align": { + "accepts": [ + "string" + ], + "camelKey": "align", + "comment": "Alignment of the headline, content, or icon.", + "default": "start", + "kind": "scalar", + "name": "align", + "optional": true, + "options": { + "values": [ + "start", + "center", + "end" + ] + }, + "types": [ + "select" + ] + }, + "arrangement": { + "accepts": [ + "string" + ], + "camelKey": "arrangement", + "comment": "Arrangement of the preheading, either left or above the header. On smaller screens, the preheading is always placed on top.", + "config": "style.title.arrangement", + "default": "above", + "kind": "scalar", + "name": "arrangement", + "optional": true, + "options": { + "values": [ + "above", + "first" + ] + }, + "types": [ + "select" + ] + }, + "content": { + "accepts": [ + "string" + ], + "camelKey": "content", + "comment": "Section content displayed below the title.", + "kind": "scalar", + "name": "content", + "optional": true, + "types": [ + "string", + "template.HTML" + ] + }, + "preheading": { + "accepts": [ + "string" + ], + "camelKey": "preheading", + "comment": "Preheading of the section heading.", + "kind": "scalar", + "name": "preheading", + "optional": true, + "types": [ + "string" + ] + }, + "size": { + "accepts": [ + "int" + ], + "camelKey": "size", + "comment": "Display size of the headline.", + "config": "style.title.size", + "default": 4, + "kind": "scalar", + "name": "size", + "optional": true, + "options": { + "max": 6, + "min": 1 + }, + "types": [ + "int" + ] + }, + "title": { + "accepts": [ + "string" + ], + "camelKey": "title", + "comment": "Title of the element. If the element references a (local) page, the title overrides the referenced page's title.", + "kind": "scalar", + "name": "title", + "optional": true, + "types": [ + "string", + "hstring.RenderedString", + "hstring.HTML", + "template.HTML" + ] + }, + "width": { + "accepts": [ + "int" + ], + "camelKey": "width", + "comment": "Column width of the element. For embedded elements, the width is relative to the parent's container.", + "default": 8, + "kind": "scalar", + "name": "width", + "optional": true, + "options": { + "max": 12, + "min": 1 + }, + "types": [ + "int" + ] + } + }, + "comment": "Heading of the content block, including a preheading and content element.", + "kind": "dict", + "name": "heading", + "optional": true, + "types": [ + "heading" + ], + "udtType": "heading" + }, + "locations": { + "accepts": [ + "slice", + "maplist" + ], + "camelKey": "locations", + "children": { + "address": { + "accepts": [ + "string" + ], + "camelKey": "address", + "comment": "Address information.", + "kind": "scalar", + "name": "address", + "optional": false, + "types": [ + "string" + ] + }, + "anchor": { + "accepts": [ + "string" + ], + "camelKey": "anchor", + "comment": "Anchor of the image's crop box, defaults to anchor value set in `imaging` section of the site configuration (usually `Smart`).", + "kind": "scalar", + "name": "anchor", + "optional": true, + "options": { + "values": [ + "TopLeft", + "Top", + "TopRight", + "Left", + "Center", + "Right", + "BottomLeft", + "Bottom", + "BottomRight", + "Smart" + ] + }, + "types": [ + "select" + ] + }, + "image": { + "accepts": [ + "string" + ], + "camelKey": "image", + "comment": "Image to include in the content block or section heading.", + "kind": "scalar", + "name": "image", + "optional": true, + "types": [ + "string" + ] + }, + "instructions": { + "accepts": [ + "slice", + "maplist" + ], + "camelKey": "instructions", + "children": { + "description": { + "accepts": [ + "string" + ], + "camelKey": "description", + "comment": "Description of the element.", + "kind": "scalar", + "name": "description", + "optional": true, + "types": [ + "string", + "template.HTML" + ] + }, + "title": { + "accepts": [ + "string" + ], + "camelKey": "title", + "comment": "Title of the element. If the element references a (local) page, the title overrides the referenced page's title.", + "kind": "scalar", + "name": "title", + "optional": true, + "types": [ + "string", + "hstring.RenderedString", + "hstring.HTML", + "template.HTML" + ] + } + }, + "comment": "Instructions how to reach a location using a specific transport method.", + "kind": "list", + "name": "instructions", + "optional": true, + "types": [ + "instructions" + ], + "udtType": "instructions" + }, + "mode": { + "accepts": [ + "bool" + ], + "camelKey": "mode", + "comment": "Flag indicating if the media asset should support color modes. If set, the element searches for images having a matching color-mode suffix such as `-light` or `-dark`.", + "default": false, + "kind": "scalar", + "name": "mode", + "optional": true, + "types": [ + "bool" + ] + }, + "phone": { + "accepts": [ + "string" + ], + "camelKey": "phone", + "comment": "Phone number.", + "kind": "scalar", + "name": "phone", + "optional": true, + "types": [ + "string" + ] + }, + "title": { + "accepts": [ + "string" + ], + "camelKey": "title", + "comment": "Title of the element. If the element references a (local) page, the title overrides the referenced page's title.", + "kind": "scalar", + "name": "title", + "optional": true, + "types": [ + "string", + "hstring.RenderedString", + "hstring.HTML", + "template.HTML" + ] + } + }, + "comment": "Office location data.", + "kind": "list", + "name": "locations", + "optional": true, + "types": [ + "locations" + ], + "udtType": "locations" + }, + "title": { + "accepts": [ + "string" + ], + "camelKey": "title", + "comment": "Title of the element. If the element references a (local) page, the title overrides the referenced page's title.", + "kind": "scalar", + "name": "title", + "optional": true, + "types": [ + "string" + ] + } + } + } + }, + "positions": { + "schema": { + "err": false, + "errmsg": [], + "positions": { + "0": "name", + "1": "kind" + }, + "schema": { + "flag": { + "accepts": [ + "bool" + ], + "camelKey": "flag", + "kind": "scalar", + "name": "flag", + "optional": true, + "types": [ + "bool" + ] + }, + "kind": { + "accepts": [ + "string" + ], + "camelKey": "kind", + "kind": "scalar", + "name": "kind", + "optional": true, + "position": 1, + "types": [ + "string" + ] + }, + "name": { + "accepts": [ + "string" + ], + "camelKey": "name", + "comment": "Name of the code snippet, used to identify the relevant section of the input file.", + "kind": "scalar", + "name": "name", + "optional": false, + "position": 0, + "types": [ + "string" + ] + }, + "partial-only": { + "accepts": [ + "string" + ], + "camelKey": "partialOnly", + "group": [ + "partial" + ], + "kind": "scalar", + "name": "partial-only", + "optional": false, + "types": [ + "string" + ] + } + } + } + }, + "scalar-structure": { + "schema": { + "err": false, + "errmsg": [], + "positions": {}, + "schema": { + "count": { + "accepts": [ + "int" + ], + "camelKey": "count", + "kind": "scalar", + "name": "count", + "optional": true, + "types": [ + "int" + ] + }, + "data": { + "accepts": [ + "map", + "maplist" + ], + "camelKey": "data", + "comment": "Path of the input data relative to the site's data folder. Supported data formats include `JSON`, `TOML`, `YAML`, and `XML`. You can omit the file extension.", + "kind": "dict", + "name": "data", + "optional": true, + "types": [ + "dict" + ] + }, + "flag": { + "accepts": [ + "bool" + ], + "camelKey": "flag", + "kind": "scalar", + "name": "flag", + "optional": true, + "types": [ + "bool" + ] + }, + "items-list": { + "accepts": [ + "slice", + "maplist" + ], + "camelKey": "itemsList", + "kind": "list", + "name": "items-list", + "optional": true, + "types": [ + "slice" + ] + }, + "label": { + "accepts": [ + "string" + ], + "camelKey": "label", + "comment": "Assistive label of the element.", + "kind": "scalar", + "name": "label", + "optional": true, + "types": [ + "string" + ] + }, + "ratio-value": { + "accepts": [ + "float", + "int" + ], + "camelKey": "ratioValue", + "kind": "scalar", + "name": "ratio-value", + "optional": true, + "types": [ + "float" + ] + } + } + } + } +} \ No newline at end of file From f6f814385fae0b347e92569054bb02dabd12fc9d Mon Sep 17 00:00:00 2001 From: Mark Dumay <61946753+markdumay@users.noreply.github.com> Date: Sun, 12 Jul 2026 08:43:33 +0200 Subject: [PATCH 12/21] fix: pass through reflect-type names and flatten schema error messages Two accepted design-gap fixes in the ArgsSchema compiler: - Package-qualified Go reflect-type names declared in _arguments.yml (e.g. template.HTML, hstring.HTML) no longer emit 'schema: unknown type'; they are collected verbatim on the node as an optional 'reflects' field ([]string, declared order) for a printf "%T" match at validation time, restoring legacy parity. Dot-less unresolvable type names still error. - Child error messages are appended element-wise instead of as whole slices, defeating a Hugo append type flip-flop that nested sub-arrays into errmsg when zero-error and error-producing UDT members interleaved. nested-structure and bookshop-blueprint now compile err:false with empty errmsg; compile-errors keeps exactly its two real messages as a flat array. Only the schema golden changed; the 10 pre-existing goldens remain byte-identical. Co-Authored-By: Claude Fable 5 --- layouts/_partials/utilities/ArgsSchema.html | 22 +++++-- tests/golden/schema.json | 70 ++++++++++++--------- 2 files changed, 57 insertions(+), 35 deletions(-) diff --git a/layouts/_partials/utilities/ArgsSchema.html b/layouts/_partials/utilities/ArgsSchema.html index b8ce904..f22771c 100644 --- a/layouts/_partials/utilities/ArgsSchema.html +++ b/layouts/_partials/utilities/ArgsSchema.html @@ -8,7 +8,10 @@ Compile the argument schema of a structure or bookshop component into a self-contained, recursive schema tree. The result is a pure function of the site's data files; invoke through partialCached keyed by the (structure, bookshop, child) triple. See the plan/spec for the - node shape. + node shape. In addition to the fields documented there, a node carries an optional + "reflects" field: a []string of raw package-qualified Go reflect-type names (e.g. + "template.HTML") declared in the data files, matched verbatim against printf "%T" at + validation time. */}} {{ define "_partials/inline/camel-key.html" }} @@ -58,6 +61,7 @@ {{ $children := dict }} {{ $udtType := "" }} {{ $udts := 0 }} + {{ $reflects := slice }} {{ range $type := $declared }} {{ if in (slice "string" "path" "select" "url") $type }} @@ -78,7 +82,13 @@ {{ else }} {{ $shape := index $typesData $type }} {{ if eq $shape nil }} - {{ $errmsg = $errmsg | append (printf "schema: unknown type '%s' for '%s'" $type $path) }} + {{ if strings.Contains $type "." }} + {{/* package-qualified Go reflect-type name (e.g. template.HTML): pass through + verbatim for a printf "%T" match at validation time, as legacy did */}} + {{ $reflects = $reflects | append $type }} + {{ else }} + {{ $errmsg = $errmsg | append (printf "schema: unknown type '%s' for '%s'" $type $path) }} + {{ end }} {{ else if in $stack $type }} {{ $errmsg = $errmsg | append (printf "schema: circular type reference '%s' for '%s'" $type $path) }} {{ else }} @@ -103,7 +113,9 @@ "stack" ($stack | append $type) "path" (printf "%s.%s" $path $member) ) }} - {{ $errmsg = $errmsg | append $child.errmsg }} + {{/* element-wise append: appending a whole slice nests instead of + flattening once the accumulator's concrete type diverges */}} + {{ range $child.errmsg }}{{ $errmsg = $errmsg | append . }}{{ end }} {{ with $child.node }}{{ $children = merge $children (dict $member .) }}{{ end }} {{ end }} {{ end }} @@ -123,6 +135,7 @@ "optional" (default false $def.optional) }} {{ if $children }}{{ $node = merge $node (dict "children" $children "udtType" $udtType) }}{{ end }} + {{ if $reflects }}{{ $node = merge $node (dict "reflects" $reflects) }}{{ end }} {{ if ne $def.default nil }}{{ $node = merge $node (dict "default" $def.default) }}{{ end }} {{ with $def.config }}{{ $node = merge $node (dict "config" .) }}{{ end }} {{ with $def.options }}{{ $node = merge $node (dict "options" .) }}{{ end }} @@ -203,7 +216,8 @@ "stack" slice "path" (printf "%s.%s" $name $key) ) }} - {{ $errmsg = $errmsg | append $result.errmsg }} + {{/* element-wise append: see note in compile-node.html */}} + {{ range $result.errmsg }}{{ $errmsg = $errmsg | append . }}{{ end }} {{ with $result.node }} {{ $schema = merge $schema (dict $key .) }} {{ if isset . "position" }} diff --git a/tests/golden/schema.json b/tests/golden/schema.json index e130e25..42d9e1a 100644 --- a/tests/golden/schema.json +++ b/tests/golden/schema.json @@ -1,15 +1,8 @@ { "bookshop-blueprint": { "schema": { - "err": true, - "errmsg": [ - "schema: unknown type 'template.HTML' for 'test-hero.heading.content'", - [ - "schema: unknown type 'hstring.RenderedString' for 'test-hero.heading.title'", - "schema: unknown type 'hstring.HTML' for 'test-hero.heading.title'", - "schema: unknown type 'template.HTML' for 'test-hero.heading.title'" - ] - ], + "err": false, + "errmsg": [], "positions": {}, "schema": { "_bookshop_name": { @@ -101,6 +94,9 @@ "kind": "scalar", "name": "content", "optional": true, + "reflects": [ + "template.HTML" + ], "types": [ "string", "template.HTML" @@ -147,6 +143,11 @@ "kind": "scalar", "name": "title", "optional": true, + "reflects": [ + "hstring.RenderedString", + "hstring.HTML", + "template.HTML" + ], "types": [ "string", "hstring.RenderedString", @@ -315,10 +316,7 @@ "err": true, "errmsg": [ "schema: unknown type 'no-such-type' for 'test-cycle.bogus-typed'", - "schema: missing type for 'test-cycle.root-node.member-without-global-def'", - "schema: unknown type 'hstring.RenderedString' for 'test-cycle.root-node.title'", - "schema: unknown type 'hstring.HTML' for 'test-cycle.root-node.title'", - "schema: unknown type 'template.HTML' for 'test-cycle.root-node.title'" + "schema: missing type for 'test-cycle.root-node.member-without-global-def'" ], "positions": {}, "schema": { @@ -347,6 +345,11 @@ "kind": "scalar", "name": "title", "optional": true, + "reflects": [ + "hstring.RenderedString", + "hstring.HTML", + "template.HTML" + ], "types": [ "string", "hstring.RenderedString", @@ -368,24 +371,8 @@ }, "nested-structure": { "schema": { - "err": true, - "errmsg": [ - "schema: unknown type 'template.HTML' for 'test-nested.heading.content'", - [ - "schema: unknown type 'hstring.RenderedString' for 'test-nested.heading.title'", - "schema: unknown type 'hstring.HTML' for 'test-nested.heading.title'", - "schema: unknown type 'template.HTML' for 'test-nested.heading.title'" - ], - "schema: unknown type 'template.HTML' for 'test-nested.locations.instructions.description'", - "schema: unknown type 'hstring.RenderedString' for 'test-nested.locations.instructions.title'", - "schema: unknown type 'hstring.HTML' for 'test-nested.locations.instructions.title'", - "schema: unknown type 'template.HTML' for 'test-nested.locations.instructions.title'", - [ - "schema: unknown type 'hstring.RenderedString' for 'test-nested.locations.title'", - "schema: unknown type 'hstring.HTML' for 'test-nested.locations.title'", - "schema: unknown type 'template.HTML' for 'test-nested.locations.title'" - ] - ], + "err": false, + "errmsg": [], "positions": {}, "schema": { "heading": { @@ -445,6 +432,9 @@ "kind": "scalar", "name": "content", "optional": true, + "reflects": [ + "template.HTML" + ], "types": [ "string", "template.HTML" @@ -491,6 +481,11 @@ "kind": "scalar", "name": "title", "optional": true, + "reflects": [ + "hstring.RenderedString", + "hstring.HTML", + "template.HTML" + ], "types": [ "string", "hstring.RenderedString", @@ -602,6 +597,9 @@ "kind": "scalar", "name": "description", "optional": true, + "reflects": [ + "template.HTML" + ], "types": [ "string", "template.HTML" @@ -616,6 +614,11 @@ "kind": "scalar", "name": "title", "optional": true, + "reflects": [ + "hstring.RenderedString", + "hstring.HTML", + "template.HTML" + ], "types": [ "string", "hstring.RenderedString", @@ -669,6 +672,11 @@ "kind": "scalar", "name": "title", "optional": true, + "reflects": [ + "hstring.RenderedString", + "hstring.HTML", + "template.HTML" + ], "types": [ "string", "hstring.RenderedString", From 57ca49c3c4bf42bcd0a07885e5769419a69ad80d Mon Sep 17 00:00:00 2001 From: Mark Dumay <61946753+markdumay@users.noreply.github.com> Date: Sun, 12 Jul 2026 09:02:22 +0200 Subject: [PATCH 13/21] feat: add Args entry point with recursive validation - Fixes nested defaults, zero-deep validation, falsy-value skips, and or-based config fallthrough on the strict path (spec defects 1-4, 6) - Adds side-by-side strict results to every golden group Co-Authored-By: Claude Fable 5 --- exampleSite/layouts/tests/single.json | 2 + layouts/_partials/utilities/Args.html | 373 ++++++++++++++++++++++++++ tests/golden/bookshop.json | 144 ++++++++++ tests/golden/casting.json | 124 +++++++++ tests/golden/child.json | 28 ++ tests/golden/defaults.json | 97 +++++++ tests/golden/envelope.json | 21 ++ tests/golden/frontmatter.json | 30 +++ tests/golden/nesting.json | 159 +++++++++++ tests/golden/options.json | 79 ++++++ tests/golden/positional.json | 31 +++ tests/golden/required.json | 62 +++++ 12 files changed, 1150 insertions(+) create mode 100644 layouts/_partials/utilities/Args.html diff --git a/exampleSite/layouts/tests/single.json b/exampleSite/layouts/tests/single.json index ff35805..ef2923f 100644 --- a/exampleSite/layouts/tests/single.json +++ b/exampleSite/layouts/tests/single.json @@ -25,6 +25,8 @@ {{- with $case.child }}{{ $params = merge $params (dict "child" .) }}{{ end -}} {{- with $case.group }}{{ $params = merge $params (dict "group" .) }}{{ end -}} {{- $entry = dict "initargs" (partial "utilities/InitArgs.html" $params) -}} + {{- $strict := partial "utilities/Args.html" (merge $params (dict "strict" true)) -}} + {{- $entry = merge $entry (dict "args" $strict) -}} {{- end -}} {{- $results = merge $results (dict $case.name $entry) -}} {{- end -}} diff --git a/layouts/_partials/utilities/Args.html b/layouts/_partials/utilities/Args.html new file mode 100644 index 0000000..5122789 --- /dev/null +++ b/layouts/_partials/utilities/Args.html @@ -0,0 +1,373 @@ + + +{{/* + Validate and initialize arguments against a compiled schema (see utilities/ArgsSchema.html). + Returns a separated envelope: user values never share a namespace with bookkeeping keys. + Keys in "args" are camelCase (canonical); "defaulted" lists dotted paths of defaulted values. +*/}} + +{{ define "_partials/inline/site-param.html" }} + {{/* isset-based site-parameter lookup: an explicit false/0 value is found and honored */}} + {{ $current := site.Params }} + {{ $found := true }} + {{ range $segment := split .path "." }} + {{ if and $found (reflect.IsMap $current) (isset $current $segment) }} + {{ $current = index $current $segment }} + {{ else }} + {{ $found = false }} + {{ end }} + {{ end }} + {{/* this Hugo/Go template version rejects a bare 'nil' as a pipeline command; a missing-key + map field access is the supported way to obtain a literal nil value */}} + {{ $none := dict }} + {{ if not $found }}{{ $current = $none.missing }}{{ end }} + {{ return (dict "found" $found "value" $current) }} +{{ end }} + +{{ define "_partials/inline/value-kind.html" }} + {{/* map a value to its kind token: string, bool, int, float, map, slice, maplist, other */}} + {{ $value := . }} + {{ $type := printf "%T" $value }} + {{ $kind := "other" }} + {{ if eq $type "string" }}{{ $kind = "string" }} + {{ else if eq $type "bool" }}{{ $kind = "bool" }} + {{ else if in (slice "int" "int8" "int16" "int32" "int64" "uint" "uint8" "uint16" "uint32" "uint64") $type }} + {{ $kind = "int" }} + {{ else if in (slice "float32" "float64") $type }}{{ $kind = "float" }} + {{ else if reflect.IsMap $value }}{{ $kind = "map" }} + {{ else if reflect.IsSlice $value }} + {{ $kind = "slice" }} + {{ if gt (len $value) 0 }} + {{ $maps := true }} + {{ range $value }}{{ if not (reflect.IsMap .) }}{{ $maps = false }}{{ break }}{{ end }}{{ end }} + {{ if $maps }}{{ $kind = "maplist" }}{{ end }} + {{ end }} + {{ end }} + {{ return $kind }} +{{ end }} + +{{ define "_partials/inline/validate-node.html" }} + {{/* + in: value, node, path, name, depth (int), provided (bool) + out: dict "value" (with defaults and casts applied; nil when absent without default), + "errmsg" (errors in every mode), "newmsg" (newly detectable class: error when strict, + warning in legacy mode), "warnmsg", "defaulted" (dotted paths) + + A node's "accepts" list matches on kind tokens; a node may also carry "reflects", a + []string of package-qualified Go reflect-type names (e.g. "template.HTML") matched + verbatim against printf "%T" of the value, for types that never reduce to a plain kind. + */}} + {{ $value := .value }} + {{ $node := .node }} + {{ $path := .path }} + {{ $name := .name }} + {{ $depth := .depth }} + {{ $provided := .provided }} + {{ $errmsg := slice }} + {{ $newmsg := slice }} + {{ $warnmsg := slice }} + {{ $defaulted := slice }} + {{ $outcome := dict }} + + {{/* deprecation warning when the caller provided an actual value */}} + {{ if and $provided $node.deprecated }} + {{ $warn := printf "[%s] argument '%s': deprecated in v%s" $name $path (strings.TrimPrefix "v" $node.deprecated) }} + {{ with $node.alternative }}{{ $warn = printf "%s, use '%s' instead" $warn . }}{{ end }} + {{ $warnmsg = $warnmsg | append $warn }} + {{ end }} + + {{/* default application: null/absent means "not provided" at every level */}} + {{ $absent := false }} + {{ if eq $value nil }} + {{ $resolved := false }} + {{ with $node.config }} + {{ $lookup := partial "inline/site-param.html" (dict "path" .) }} + {{ if $lookup.found }}{{ $value = $lookup.value }}{{ $resolved = true }}{{ end }} + {{ end }} + {{ if and (not $resolved) (isset $node "default") }} + {{ $value = $node.default }} + {{ $resolved = true }} + {{ end }} + {{ if $resolved }} + {{ $defaulted = $defaulted | append $path }} + {{ else if $node.children }} + {{/* fill the shape of an absent user-defined type so templates can chain safely */}} + {{ if eq $node.kind "list" }} + {{ $value = slice }} + {{ else }} + {{ $shape := dict }} + {{ range $member, $childNode := $node.children }} + {{ $childRes := partial "inline/validate-node.html" (dict + "value" nil "node" $childNode "path" (printf "%s.%s" $path $member) + "name" $name "depth" (add $depth 1) "provided" false) }} + {{ if ne $childRes.value nil }} + {{ $shape = merge $shape (dict $member $childRes.value) }} + {{ range $childRes.defaulted }}{{ $defaulted = $defaulted | append . }}{{ end }} + {{ end }} + {{ end }} + {{ if $shape }}{{ $value = $shape }}{{ end }} + {{ end }} + {{ end }} + {{ if eq $value nil }} + {{ $absent = true }} + {{ end }} + {{ end }} + + {{ if $absent }} + {{ $outcome = dict "value" nil "errmsg" $errmsg "newmsg" $newmsg "warnmsg" $warnmsg "defaulted" $defaulted }} + {{ else }} + {{/* casting between strings and scalars */}} + {{ $kind := partial "inline/value-kind.html" $value }} + {{ if eq $kind "string" }} + {{ if and (in $node.accepts "bool") (in (slice "true" "false") $value) }} + {{ $value = eq $value "true" }} + {{ $kind = "bool" }} + {{ else if and (in $node.accepts "int") (findRE `^-?\d+$` $value) }} + {{ $value = int $value }} + {{ $kind = "int" }} + {{ else if and (in $node.accepts "float") (findRE `^-?(\d+\.\d+|\.\d+)$` $value) }} + {{ $value = float $value }} + {{ $kind = "float" }} + {{ end }} + {{ else if and (in $node.accepts "string") (in (slice "bool" "int" "float") $kind) (not (in $node.accepts $kind)) }} + {{ $value = string $value }} + {{ $kind = "string" }} + {{ end }} + + {{/* type check: applies to every provided value, including explicit false, 0, and "" */}} + {{ $typeOk := or (in $node.accepts $kind) (in ($node.reflects | default slice) (printf "%T" $value)) }} + {{ if not $typeOk }} + {{ $msg := printf "[%s] argument '%s': expected type '%s', got '%s' with value '%v'" + $name $path (delimit $node.types ", ") (printf "%T" .value) $value }} + {{ if or (gt $depth 0) (not $value) }} + {{ $newmsg = $newmsg | append $msg }} + {{ else }} + {{ $errmsg = $errmsg | append $msg }} + {{ end }} + {{ $outcome = dict "value" $value "errmsg" $errmsg "newmsg" $newmsg "warnmsg" $warnmsg "defaulted" $defaulted }} + {{ else }} + {{/* permitted values and numeric ranges */}} + {{ if and (reflect.IsMap $node.options) $node.options.values (eq $kind "string") }} + {{ if not (in $node.options.values $value) }} + {{ $msg := printf "[%s] argument '%s': unexpected value '%s'" $name $path $value }} + {{ if or (gt $depth 0) (not $value) }} + {{ $newmsg = $newmsg | append $msg }} + {{ else }} + {{ $errmsg = $errmsg | append $msg }} + {{ end }} + {{ end }} + {{ else if and (reflect.IsMap $node.options) + (or (isset $node.options "min") (isset $node.options "max")) + (in (slice "int" "float") $kind) }} + {{ if or + (and (isset $node.options "min") (lt $value $node.options.min)) + (and (isset $node.options "max") (gt $value $node.options.max)) }} + {{ $min := string (or $node.options.min "-") }} + {{ $max := string (or $node.options.max "-") }} + {{ $msg := printf "[%s] argument '%s': value '%v' out of range [%s, %s]" $name $path $value $min $max }} + {{ if or (gt $depth 0) (not $value) }} + {{ $newmsg = $newmsg | append $msg }} + {{ else }} + {{ $errmsg = $errmsg | append $msg }} + {{ end }} + {{ end }} + {{ end }} + + {{/* recursion into user-defined types */}} + {{ if and $node.children (eq $kind "map") }} + {{ $memberRes := partial "inline/validate-members.html" (dict + "value" $value "node" $node "path" $path "name" $name "depth" $depth) }} + {{ $value = $memberRes.value }} + {{ range $memberRes.errmsg }}{{ $errmsg = $errmsg | append . }}{{ end }} + {{ range $memberRes.newmsg }}{{ $newmsg = $newmsg | append . }}{{ end }} + {{ range $memberRes.warnmsg }}{{ $warnmsg = $warnmsg | append . }}{{ end }} + {{ range $memberRes.defaulted }}{{ $defaulted = $defaulted | append . }}{{ end }} + {{ else if and $node.children (in (slice "maplist" "slice") $kind) }} + {{ $elements := slice }} + {{ range $index, $element := $value }} + {{ if reflect.IsMap $element }} + {{ $memberRes := partial "inline/validate-members.html" (dict + "value" $element "node" $node + "path" (printf "%s[%d]" $path $index) "name" $name "depth" $depth) }} + {{ $elements = $elements | append $memberRes.value }} + {{ range $memberRes.errmsg }}{{ $errmsg = $errmsg | append . }}{{ end }} + {{ range $memberRes.newmsg }}{{ $newmsg = $newmsg | append . }}{{ end }} + {{ range $memberRes.warnmsg }}{{ $warnmsg = $warnmsg | append . }}{{ end }} + {{ range $memberRes.defaulted }}{{ $defaulted = $defaulted | append . }}{{ end }} + {{ else }} + {{ $newmsg = $newmsg | append (printf "[%s] argument '%s[%d]': expected map element, got '%T'" $name $path $index $element) }} + {{ $elements = $elements | append $element }} + {{ end }} + {{ end }} + {{ $value = $elements }} + {{ end }} + + {{ $outcome = dict "value" $value "errmsg" $errmsg "newmsg" $newmsg "warnmsg" $warnmsg "defaulted" $defaulted }} + {{ end }} + {{ end }} + + {{ return $outcome }} +{{ end }} + +{{ define "_partials/inline/validate-members.html" }} + {{/* validate the members of one map value against node.children; fills member defaults */}} + {{ $value := .value }} + {{ $node := .node }} + {{ $path := .path }} + {{ $name := .name }} + {{ $depth := .depth }} + {{ $errmsg := slice }} + {{ $newmsg := slice }} + {{ $warnmsg := slice }} + {{ $defaulted := slice }} + {{ $out := dict }} + + {{ range $memberKey, $memberVal := $value }} + {{ $childNode := index $node.children $memberKey }} + {{ $declaredKey := $memberKey }} + {{ if not $childNode }} + {{/* content uses snake_case (bookshop) while members are declared kebab-case, or vice versa */}} + {{ $match := findRESubmatch "^(_*)(.*)$" (string $memberKey) }} + {{ $body := index $match 0 2 }} + {{ $alt := print (index $match 0 1) (cond (strings.Contains $body "_") (replaceRE "_" "-" $body) (replaceRE "-" "_" $body)) }} + {{ with index $node.children $alt }} + {{ $childNode = . }} + {{ $declaredKey = $alt }} + {{ end }} + {{ end }} + {{ if not $childNode }} + {{ $newmsg = $newmsg | append (printf "[%s] argument '%s': unsupported attribute '%s'" $name $path $memberKey) }} + {{ $out = merge $out (dict (string $memberKey) $memberVal) }} + {{ else }} + {{ $childRes := partial "inline/validate-node.html" (dict + "value" $memberVal "node" $childNode + "path" (printf "%s.%s" $path $declaredKey) + "name" $name "depth" (add $depth 1) + "provided" (ne $memberVal nil)) }} + {{ range $childRes.errmsg }}{{ $errmsg = $errmsg | append . }}{{ end }} + {{ range $childRes.newmsg }}{{ $newmsg = $newmsg | append . }}{{ end }} + {{ range $childRes.warnmsg }}{{ $warnmsg = $warnmsg | append . }}{{ end }} + {{ range $childRes.defaulted }}{{ $defaulted = $defaulted | append . }}{{ end }} + {{ if ne $childRes.value nil }} + {{ $out = merge $out (dict $declaredKey $childRes.value) }} + {{ end }} + {{ end }} + {{ end }} + + {{/* fill defaults for members the caller omitted entirely */}} + {{ range $memberKey, $childNode := $node.children }} + {{ $snake := replaceRE "-" "_" $memberKey }} + {{ if and (not (isset $out $memberKey)) (not (isset $value $memberKey)) (not (isset $value $snake)) }} + {{ $childRes := partial "inline/validate-node.html" (dict + "value" nil "node" $childNode + "path" (printf "%s.%s" $path $memberKey) + "name" $name "depth" (add $depth 1) "provided" false) }} + {{ if ne $childRes.value nil }} + {{ $out = merge $out (dict $memberKey $childRes.value) }} + {{ range $childRes.defaulted }}{{ $defaulted = $defaulted | append . }}{{ end }} + {{ end }} + {{ end }} + {{ end }} + + {{ return (dict "value" $out "errmsg" $errmsg "newmsg" $newmsg "warnmsg" $warnmsg "defaulted" $defaulted) }} +{{ end }} + +{{/* main body */}} +{{ $structure := .structure }} +{{ $bookshop := .bookshop }} +{{ $child := .child }} +{{ $named := .named | default true }} +{{ $args := .args | default dict }} +{{ $group := .group }} +{{ $strict := ne .strict false }} + +{{ $errmsg := slice }} +{{ $newmsg := slice }} +{{ $warnmsg := slice }} +{{ $defaulted := slice }} +{{ $out := dict }} +{{ $name := or $structure $bookshop }} + +{{ $compiled := partial "utilities/ArgsSchema.html" (dict "structure" $structure "bookshop" $bookshop "child" $child) }} +{{ if $compiled.err }} + {{ range $compiled.errmsg }}{{ $errmsg = $errmsg | append . }}{{ end }} +{{ else }} + {{ $schema := $compiled.schema }} + + {{/* map positional arguments to their declared names */}} + {{ $provided := dict }} + {{ if $named }} + {{ $provided = $args }} + {{ else }} + {{ range $index, $val := $args }} + {{ with index $compiled.positions (string $index) }} + {{ $provided = merge $provided (dict . $val) }} + {{ else }} + {{ $errmsg = $errmsg | append (printf "[%s] unsupported argument at index %d (value: '%s')" $name $index $val) }} + {{ end }} + {{ end }} + {{ end }} + + {{/* normalize provided keys to declared names; collect all unknown-argument errors */}} + {{ $normalized := dict }} + {{ range $key, $val := $provided }} + {{ if eq $key "_default" }} + {{ $defaulted = $defaulted | append $val }} + {{ else }} + {{ $declared := string $key }} + {{ if and (not (isset $schema $declared)) $bookshop }} + {{ $match := findRESubmatch "^(_*)(.*)$" $declared }} + {{ $body := index $match 0 2 }} + {{ $alt := print (index $match 0 1) (cond (strings.Contains $body "_") (replaceRE "_" "-" $body) (replaceRE "-" "_" $body)) }} + {{ if isset $schema $alt }}{{ $declared = $alt }}{{ end }} + {{ end }} + {{ if and $bookshop (strings.Contains (string $key) "-") (isset $schema $declared) }} + {{ $warnmsg = $warnmsg | append (printf "[%s] argument '%s': prefer snake_case '%s'" $name $key (replaceRE "-" "_" (string $key))) }} + {{ end }} + {{ if not (isset $schema $declared) }} + {{ $errmsg = $errmsg | append (printf "[%s] unsupported argument '%s'" $name $key) }} + {{ else }} + {{ $normalized = merge $normalized (dict $declared $val) }} + {{ end }} + {{ end }} + {{ end }} + + {{/* validate every schema argument and enforce required ones */}} + {{ range $key, $node := $schema }} + {{ $isProvided := isset $normalized $key }} + {{ $val := index $normalized $key }} + {{ $res := partial "inline/validate-node.html" (dict + "value" $val "node" $node "path" $key "name" $name + "depth" 0 "provided" (and $isProvided (ne $val nil))) }} + {{ range $res.errmsg }}{{ $errmsg = $errmsg | append . }}{{ end }} + {{ range $res.newmsg }}{{ $newmsg = $newmsg | append . }}{{ end }} + {{ range $res.warnmsg }}{{ $warnmsg = $warnmsg | append . }}{{ end }} + {{ range $res.defaulted }}{{ $defaulted = $defaulted | append . }}{{ end }} + {{ if ne $res.value nil }} + {{ $out = merge $out (dict $node.camelKey $res.value) }} + {{ end }} + + {{ $skip := false }} + {{ if and $group $node.group }}{{ $skip = not (in $node.group $group) }}{{ end }} + {{ if and (not $skip) (not $node.optional) (not $isProvided) }} + {{ $errmsg = $errmsg | append (printf "[%s] argument '%s': expected value" $name $key) }} + {{ end }} + {{ end }} +{{ end }} + +{{ if $strict }} + {{ range $newmsg }}{{ $errmsg = $errmsg | append . }}{{ end }} +{{ else }} + {{ range $newmsg }}{{ $warnmsg = $warnmsg | append . }}{{ end }} +{{ end }} +{{ return (dict + "args" $out + "err" (gt (len $errmsg) 0) + "errmsg" $errmsg + "warnmsg" $warnmsg + "defaulted" $defaulted +) }} diff --git a/tests/golden/bookshop.json b/tests/golden/bookshop.json index dc61549..2c0298d 100644 --- a/tests/golden/bookshop.json +++ b/tests/golden/bookshop.json @@ -1,5 +1,14 @@ { "bookshop-prefix-via-structure": { + "args": { + "args": {}, + "defaulted": [], + "err": true, + "errmsg": [ + "[bookshop-test-hero] unsupported argument 'heading'" + ], + "warnmsg": [] + }, "initargs": { "default": [ "link_type", @@ -16,6 +25,33 @@ } }, "cloudcannon-explicit-false": { + "args": { + "args": { + "_bookshopName": "test-hero", + "heading": { + "align": "start", + "arrangement": "above", + "size": 4, + "title": "Hello", + "width": 8 + }, + "linkType": "button", + "showMore": false, + "width": 0 + }, + "defaulted": [ + "heading.align", + "heading.arrangement", + "heading.size", + "heading.width", + "link_type" + ], + "err": true, + "errmsg": [ + "[test-hero] argument 'width': value '0' out of range [1, 12]" + ], + "warnmsg": [] + }, "initargs": { "_bookshop_name": "test-hero", "default": [], @@ -31,6 +67,34 @@ } }, "cloudcannon-null-heavy": { + "args": { + "args": { + "_bookshopName": "test-hero", + "_ordinal": 0, + "heading": { + "align": "start", + "arrangement": "above", + "size": 4, + "title": "Hello", + "width": 8 + }, + "linkType": "button", + "showMore": false, + "width": 8 + }, + "defaulted": [ + "heading.align", + "heading.arrangement", + "heading.size", + "heading.width", + "link_type", + "show_more", + "width" + ], + "err": false, + "errmsg": [], + "warnmsg": [] + }, "initargs": { "_bookshop_name": "test-hero", "_ordinal": 0, @@ -51,6 +115,33 @@ } }, "kebab-case-warns": { + "args": { + "args": { + "heading": { + "align": "start", + "arrangement": "above", + "size": 4, + "title": "Hello", + "width": 8 + }, + "linkType": "link", + "showMore": false, + "width": 8 + }, + "defaulted": [ + "heading.align", + "heading.arrangement", + "heading.size", + "heading.width", + "show_more", + "width" + ], + "err": false, + "errmsg": [], + "warnmsg": [ + "[test-hero] argument 'link-type': prefer snake_case 'link_type'" + ] + }, "initargs": { "default": [ "link_type", @@ -71,6 +162,31 @@ } }, "snake-case-args": { + "args": { + "args": { + "_bookshopName": "test-hero", + "heading": { + "align": "start", + "arrangement": "above", + "size": 4, + "title": "Hello", + "width": 8 + }, + "linkType": "link", + "showMore": true, + "width": 8 + }, + "defaulted": [ + "heading.align", + "heading.arrangement", + "heading.size", + "heading.width", + "width" + ], + "err": false, + "errmsg": [], + "warnmsg": [] + }, "initargs": { "_bookshop_name": "test-hero", "default": [ @@ -88,6 +204,34 @@ } }, "unknown-bookshop-arg": { + "args": { + "args": { + "heading": { + "align": "start", + "arrangement": "above", + "size": 4, + "title": "Hello", + "width": 8 + }, + "linkType": "button", + "showMore": false, + "width": 8 + }, + "defaulted": [ + "heading.align", + "heading.arrangement", + "heading.size", + "heading.width", + "link_type", + "show_more", + "width" + ], + "err": true, + "errmsg": [ + "[test-hero] unsupported argument 'bogus'" + ], + "warnmsg": [] + }, "initargs": { "default": [], "err": true, diff --git a/tests/golden/casting.json b/tests/golden/casting.json index a74befe..3a6fb24 100644 --- a/tests/golden/casting.json +++ b/tests/golden/casting.json @@ -1,5 +1,14 @@ { "bool-arg-real-bool": { + "args": { + "args": { + "flag": true + }, + "defaulted": [], + "err": false, + "errmsg": [], + "warnmsg": [] + }, "initargs": { "default": [], "err": false, @@ -9,6 +18,17 @@ } }, "empty-string-float": { + "args": { + "args": { + "ratioValue": "" + }, + "defaulted": [], + "err": true, + "errmsg": [ + "[test-cast] argument 'ratio-value': expected type 'float', got 'string' with value ''" + ], + "warnmsg": [] + }, "initargs": { "default": [], "err": false, @@ -19,6 +39,17 @@ } }, "falsy-wrong-type-skips-validation": { + "args": { + "args": { + "count": false + }, + "defaulted": [], + "err": true, + "errmsg": [ + "[test-cast] argument 'count': expected type 'int', got 'bool' with value 'false'" + ], + "warnmsg": [] + }, "initargs": { "count": false, "default": [], @@ -28,6 +59,17 @@ } }, "generic-dict-as-map": { + "args": { + "args": { + "data": { + "anything": "goes" + } + }, + "defaulted": [], + "err": false, + "errmsg": [], + "warnmsg": [] + }, "initargs": { "default": [], "err": true, @@ -38,6 +80,19 @@ } }, "generic-dict-as-maplist": { + "args": { + "args": { + "data": [ + { + "anything": "goes" + } + ] + }, + "defaulted": [], + "err": false, + "errmsg": [], + "warnmsg": [] + }, "initargs": { "default": [], "err": true, @@ -48,6 +103,19 @@ } }, "generic-slice": { + "args": { + "args": { + "itemsList": [ + 1, + "two", + true + ] + }, + "defaulted": [], + "err": false, + "errmsg": [], + "warnmsg": [] + }, "initargs": { "default": [], "err": false, @@ -66,6 +134,15 @@ } }, "int-to-string": { + "args": { + "args": { + "label": "7" + }, + "defaulted": [], + "err": false, + "errmsg": [], + "warnmsg": [] + }, "initargs": { "default": [], "err": true, @@ -76,6 +153,15 @@ } }, "real-float-value": { + "args": { + "args": { + "ratioValue": 1.5 + }, + "defaulted": [], + "err": false, + "errmsg": [], + "warnmsg": [] + }, "initargs": { "default": [], "err": false, @@ -86,6 +172,15 @@ } }, "string-to-bool": { + "args": { + "args": { + "flag": true + }, + "defaulted": [], + "err": false, + "errmsg": [], + "warnmsg": [] + }, "initargs": { "default": [], "err": false, @@ -95,6 +190,15 @@ } }, "string-to-float": { + "args": { + "args": { + "ratioValue": 1.5 + }, + "defaulted": [], + "err": false, + "errmsg": [], + "warnmsg": [] + }, "initargs": { "default": [], "err": false, @@ -105,6 +209,15 @@ } }, "string-to-int": { + "args": { + "args": { + "count": 42 + }, + "defaulted": [], + "err": false, + "errmsg": [], + "warnmsg": [] + }, "initargs": { "count": 42, "default": [], @@ -114,6 +227,17 @@ } }, "wrong-type-errors": { + "args": { + "args": { + "count": "abc" + }, + "defaulted": [], + "err": true, + "errmsg": [ + "[test-cast] argument 'count': expected type 'int', got 'string' with value 'abc'" + ], + "warnmsg": [] + }, "initargs": { "default": [], "err": true, diff --git a/tests/golden/child.json b/tests/golden/child.json index 76c0e2c..5a393ee 100644 --- a/tests/golden/child.json +++ b/tests/golden/child.json @@ -1,5 +1,15 @@ { "child-args-cascade": { + "args": { + "args": { + "hook": "custom-hook", + "title": "Stack" + }, + "defaulted": [], + "err": false, + "errmsg": [], + "warnmsg": [] + }, "initargs": { "default": [], "err": false, @@ -10,6 +20,15 @@ } }, "child-default-stripped": { + "args": { + "args": { + "title": "Stack" + }, + "defaulted": [], + "err": false, + "errmsg": [], + "warnmsg": [] + }, "initargs": { "default": [], "err": false, @@ -19,6 +38,15 @@ } }, "child-non-parent-arg-rejected": { + "args": { + "args": {}, + "defaulted": [], + "err": true, + "errmsg": [ + "[test-stack] unsupported argument 'ignored-non-parent'" + ], + "warnmsg": [] + }, "initargs": { "default": [], "err": true, diff --git a/tests/golden/defaults.json b/tests/golden/defaults.json index ce20d15..f056c3c 100644 --- a/tests/golden/defaults.json +++ b/tests/golden/defaults.json @@ -1,5 +1,24 @@ { "config-false-fallthrough": { + "args": { + "args": { + "fromConfig": "from-site-params", + "fromConfigFalse": false, + "fromConfigMissing": "static-fallback", + "fromConfigTrue": true, + "staticLabel": "fallback" + }, + "defaulted": [ + "from-config", + "from-config-false", + "from-config-missing", + "from-config-true", + "static-label" + ], + "err": false, + "errmsg": [], + "warnmsg": [] + }, "initargs": { "default": [ "from-config", @@ -24,6 +43,25 @@ } }, "config-present-wins": { + "args": { + "args": { + "fromConfig": "from-site-params", + "fromConfigFalse": false, + "fromConfigMissing": "static-fallback", + "fromConfigTrue": true, + "staticLabel": "fallback" + }, + "defaulted": [ + "from-config", + "from-config-false", + "from-config-missing", + "from-config-true", + "static-label" + ], + "err": false, + "errmsg": [], + "warnmsg": [] + }, "initargs": { "default": [ "from-config", @@ -48,6 +86,28 @@ } }, "deprecated-warns": { + "args": { + "args": { + "fromConfig": "from-site-params", + "fromConfigFalse": false, + "fromConfigMissing": "static-fallback", + "fromConfigTrue": true, + "legacyArg": "old", + "staticLabel": "fallback" + }, + "defaulted": [ + "from-config", + "from-config-false", + "from-config-missing", + "from-config-true", + "static-label" + ], + "err": false, + "errmsg": [], + "warnmsg": [ + "[test-default] argument 'legacy-arg': deprecated in v1.2.0, use 'static-label' instead" + ] + }, "initargs": { "default": [ "from-config", @@ -76,6 +136,25 @@ } }, "static-default-applied": { + "args": { + "args": { + "fromConfig": "from-site-params", + "fromConfigFalse": false, + "fromConfigMissing": "static-fallback", + "fromConfigTrue": true, + "staticLabel": "fallback" + }, + "defaulted": [ + "from-config", + "from-config-false", + "from-config-missing", + "from-config-true", + "static-label" + ], + "err": false, + "errmsg": [], + "warnmsg": [] + }, "initargs": { "default": [ "from-config", @@ -100,6 +179,24 @@ } }, "static-default-overridden": { + "args": { + "args": { + "fromConfig": "from-site-params", + "fromConfigFalse": false, + "fromConfigMissing": "static-fallback", + "fromConfigTrue": true, + "staticLabel": "custom" + }, + "defaulted": [ + "from-config", + "from-config-false", + "from-config-missing", + "from-config-true" + ], + "err": false, + "errmsg": [], + "warnmsg": [] + }, "initargs": { "default": [ "from-config", diff --git a/tests/golden/envelope.json b/tests/golden/envelope.json index fac1c42..3063860 100644 --- a/tests/golden/envelope.json +++ b/tests/golden/envelope.json @@ -1,5 +1,17 @@ { "default-arg-collision": { + "args": { + "args": { + "default": "user-value", + "plain": "false" + }, + "defaulted": [ + "plain" + ], + "err": false, + "errmsg": [], + "warnmsg": [] + }, "initargs": { "default": [], "err": false, @@ -8,6 +20,15 @@ } }, "plain-arg": { + "args": { + "args": { + "plain": "hello" + }, + "defaulted": [], + "err": false, + "errmsg": [], + "warnmsg": [] + }, "initargs": { "default": [], "err": false, diff --git a/tests/golden/frontmatter.json b/tests/golden/frontmatter.json index 066fef5..5c9ce44 100644 --- a/tests/golden/frontmatter.json +++ b/tests/golden/frontmatter.json @@ -1,5 +1,25 @@ { "params-typed-nested-map": { + "args": { + "args": { + "heading": { + "align": "center", + "arrangement": "above", + "size": 4, + "title": "Hello", + "width": 8 + }, + "locations": [] + }, + "defaulted": [ + "heading.arrangement", + "heading.size", + "heading.width" + ], + "err": false, + "errmsg": [], + "warnmsg": [] + }, "initargs": { "default": [], "err": false, @@ -13,6 +33,16 @@ } }, "params-typed-scalars": { + "args": { + "args": { + "count": 42, + "flag": true + }, + "defaulted": [], + "err": false, + "errmsg": [], + "warnmsg": [] + }, "initargs": { "count": 42, "default": [], diff --git a/tests/golden/nesting.json b/tests/golden/nesting.json index 237186e..4f8fd77 100644 --- a/tests/golden/nesting.json +++ b/tests/golden/nesting.json @@ -1,5 +1,25 @@ { "absent-udt-shape-fill": { + "args": { + "args": { + "heading": { + "align": "start", + "arrangement": "above", + "size": 4, + "width": 8 + }, + "locations": [] + }, + "defaulted": [ + "heading.align", + "heading.arrangement", + "heading.size", + "heading.width" + ], + "err": false, + "errmsg": [], + "warnmsg": [] + }, "initargs": { "default": [], "err": false, @@ -15,6 +35,38 @@ } }, "depth-three-nesting": { + "args": { + "args": { + "heading": { + "align": "start", + "arrangement": "above", + "size": 4, + "width": 8 + }, + "locations": [ + { + "instructions": [ + { + "description": "Use lot B", + "title": "Parking" + } + ], + "mode": false, + "title": "Office" + } + ] + }, + "defaulted": [ + "heading.align", + "heading.arrangement", + "heading.size", + "heading.width", + "locations[0].mode" + ], + "err": false, + "errmsg": [], + "warnmsg": [] + }, "initargs": { "default": [], "err": false, @@ -40,6 +92,26 @@ } }, "heading-valid": { + "args": { + "args": { + "heading": { + "align": "center", + "arrangement": "above", + "size": 4, + "title": "Hello", + "width": 8 + }, + "locations": [] + }, + "defaulted": [ + "heading.arrangement", + "heading.size", + "heading.width" + ], + "err": false, + "errmsg": [], + "warnmsg": [] + }, "initargs": { "default": [], "err": false, @@ -53,6 +125,27 @@ } }, "nested-null-members": { + "args": { + "args": { + "heading": { + "align": "start", + "arrangement": "above", + "size": 4, + "title": "Hello", + "width": 8 + }, + "locations": [] + }, + "defaulted": [ + "heading.align", + "heading.arrangement", + "heading.size", + "heading.width" + ], + "err": false, + "errmsg": [], + "warnmsg": [] + }, "initargs": { "default": [], "err": false, @@ -67,6 +160,30 @@ } }, "nested-unknown-key-passes": { + "args": { + "args": { + "heading": { + "align": "start", + "arrangement": "above", + "size": 4, + "title": "Hello", + "typo-key": "oops", + "width": 8 + }, + "locations": [] + }, + "defaulted": [ + "heading.align", + "heading.arrangement", + "heading.size", + "heading.width" + ], + "err": true, + "errmsg": [ + "[test-nested] argument 'heading': unsupported attribute 'typo-key'" + ], + "warnmsg": [] + }, "initargs": { "default": [], "err": false, @@ -80,6 +197,28 @@ } }, "nested-wrong-type-passes": { + "args": { + "args": { + "heading": { + "align": "42", + "arrangement": "above", + "size": 4, + "title": "Hello", + "width": 8 + }, + "locations": [] + }, + "defaulted": [ + "heading.arrangement", + "heading.size", + "heading.width" + ], + "err": true, + "errmsg": [ + "[test-nested] argument 'heading.align': unexpected value '42'" + ], + "warnmsg": [] + }, "initargs": { "default": [], "err": false, @@ -93,6 +232,26 @@ } }, "udt-list-empty": { + "args": { + "args": { + "heading": { + "align": "start", + "arrangement": "above", + "size": 4, + "width": 8 + }, + "locations": [] + }, + "defaulted": [ + "heading.align", + "heading.arrangement", + "heading.size", + "heading.width" + ], + "err": false, + "errmsg": [], + "warnmsg": [] + }, "initargs": { "default": [], "err": false, diff --git a/tests/golden/options.json b/tests/golden/options.json index efe7a4c..1d042a8 100644 --- a/tests/golden/options.json +++ b/tests/golden/options.json @@ -1,5 +1,19 @@ { "range-above-max": { + "args": { + "args": { + "mode": "auto", + "size": 11 + }, + "defaulted": [ + "mode" + ], + "err": true, + "errmsg": [ + "[test-options] argument 'size': value '11' out of range [1, 10]" + ], + "warnmsg": [] + }, "initargs": { "default": [], "err": true, @@ -10,6 +24,20 @@ } }, "range-below-min": { + "args": { + "args": { + "mode": "auto", + "size": 0 + }, + "defaulted": [ + "mode" + ], + "err": true, + "errmsg": [ + "[test-options] argument 'size': value '0' out of range [1, 10]" + ], + "warnmsg": [] + }, "initargs": { "default": [], "err": true, @@ -20,6 +48,18 @@ } }, "range-inside": { + "args": { + "args": { + "mode": "auto", + "size": 5 + }, + "defaulted": [ + "mode" + ], + "err": false, + "errmsg": [], + "warnmsg": [] + }, "initargs": { "default": [ "mode" @@ -32,6 +72,19 @@ } }, "select-default": { + "args": { + "args": { + "mode": "auto", + "size": 4 + }, + "defaulted": [ + "mode", + "size" + ], + "err": false, + "errmsg": [], + "warnmsg": [] + }, "initargs": { "default": [ "mode", @@ -45,6 +98,20 @@ } }, "select-invalid": { + "args": { + "args": { + "mode": "bogus", + "size": 4 + }, + "defaulted": [ + "size" + ], + "err": true, + "errmsg": [ + "[test-options] argument 'mode': unexpected value 'bogus'" + ], + "warnmsg": [] + }, "initargs": { "default": [], "err": true, @@ -55,6 +122,18 @@ } }, "select-valid": { + "args": { + "args": { + "mode": "manual", + "size": 4 + }, + "defaulted": [ + "size" + ], + "err": false, + "errmsg": [], + "warnmsg": [] + }, "initargs": { "default": [ "size" diff --git a/tests/golden/positional.json b/tests/golden/positional.json index f11b638..c79154d 100644 --- a/tests/golden/positional.json +++ b/tests/golden/positional.json @@ -1,5 +1,15 @@ { "both-positions": { + "args": { + "args": { + "kind": "beta", + "name": "alpha" + }, + "defaulted": [], + "err": false, + "errmsg": [], + "warnmsg": [] + }, "initargs": { "default": [], "err": false, @@ -10,6 +20,18 @@ } }, "excess-position-errors": { + "args": { + "args": { + "kind": "beta", + "name": "alpha" + }, + "defaulted": [], + "err": true, + "errmsg": [ + "[test-required] unsupported argument at index 2 (value: 'gamma')" + ], + "warnmsg": [] + }, "initargs": { "default": [], "err": false, @@ -22,6 +44,15 @@ } }, "first-position-only": { + "args": { + "args": { + "name": "alpha" + }, + "defaulted": [], + "err": false, + "errmsg": [], + "warnmsg": [] + }, "initargs": { "default": [], "err": false, diff --git a/tests/golden/required.json b/tests/golden/required.json index 956c054..d36c2ba 100644 --- a/tests/golden/required.json +++ b/tests/golden/required.json @@ -1,5 +1,16 @@ { "first-error-wins": { + "args": { + "args": {}, + "defaulted": [], + "err": true, + "errmsg": [ + "[test-required] unsupported argument 'bogus-one'", + "[test-required] unsupported argument 'bogus-two'", + "[test-required] argument 'name': expected value" + ], + "warnmsg": [] + }, "initargs": { "default": [], "err": true, @@ -10,6 +21,15 @@ } }, "group-filter-skips-partial-arg": { + "args": { + "args": { + "name": "alpha" + }, + "defaulted": [], + "err": false, + "errmsg": [], + "warnmsg": [] + }, "initargs": { "default": [], "err": false, @@ -19,6 +39,17 @@ } }, "group-partial-requires-both": { + "args": { + "args": { + "name": "alpha" + }, + "defaulted": [], + "err": true, + "errmsg": [ + "[test-required] argument 'partial-only': expected value" + ], + "warnmsg": [] + }, "initargs": { "default": [], "err": true, @@ -30,6 +61,17 @@ } }, "no-group-requires-all": { + "args": { + "args": { + "name": "alpha" + }, + "defaulted": [], + "err": true, + "errmsg": [ + "[test-required] argument 'partial-only': expected value" + ], + "warnmsg": [] + }, "initargs": { "default": [], "err": true, @@ -41,6 +83,17 @@ } }, "required-missing-errors": { + "args": { + "args": { + "flag": true + }, + "defaulted": [], + "err": true, + "errmsg": [ + "[test-required] argument 'name': expected value" + ], + "warnmsg": [] + }, "initargs": { "default": [], "err": true, @@ -52,6 +105,15 @@ } }, "required-provided": { + "args": { + "args": { + "name": "alpha" + }, + "defaulted": [], + "err": false, + "errmsg": [], + "warnmsg": [] + }, "initargs": { "default": [], "err": false, From 293dbaf0327d3b868ca52e3f25c9ac94a2516fda Mon Sep 17 00:00:00 2001 From: Mark Dumay <61946753+markdumay@users.noreply.github.com> Date: Sun, 12 Jul 2026 09:57:03 +0200 Subject: [PATCH 14/21] refactor: rewire InitArgs as a compatibility shim over Args BREAKING-ISH (warnings only): newly detectable validation findings (nested type mismatches, unknown nested attributes, falsy-value type mismatches) surface as warnings; promotion to errors follows one release cycle later per the design spec. Golden diffs in this commit are intentional: nested defaults fixed, all errors reported (no first-error break), explicit-false site params honored, camelCase duplicates added for snake_case keys, excess-positional-argument err now reflects errmsg length, and two pre-existing merge/type-alias bugs are fixed at the ArgsSchema/Args level (required to unblock the Hinode smoke test, see below). Also fixes three latent bugs in the shared Args.html/ArgsSchema.html core, exposed for the first time by routing every InitArgs caller through it: - numeric range/min-max checks no longer get the falsy-value exemption meant only for type casting (legacy never exempted them either; fixes bookshop/cloudcannon-explicit-false staying err:true) - unknown-type detection now accepts bracket/asterisk Go type names ([]string, map[string]interface {}), not just dotted ones, fixing a hard build crash in LogErr/LogWarn's own argument validation - same-named global/local arguments that redeclare "type" no longer leak the global's default/config/options via merge's recursive nested-map behavior (fixed a hard build error on every Hinode page via button.yml's deprecated "size" colliding with the global heading "size", and fully resolves envelope/default-arg-collision) Hinode exampleSite smoke test: build now completes (previously hard crashed on the first logged warning anywhere). 45 remaining ERRORs are confined to mod-fontawesome's fas/fab/icon shortcodes, a downstream-module consequence of the intended err/errmsg semantics change on excess positional arguments, not a mod-utils defect; needs a follow-up fix in mod-fontawesome. New WARN lines (~55 distinct patterns) are the intended warnings-first surface: mostly empty- string values now flagged where legacy silently ignored them, and newly-visible nested unsupported-attribute/type findings across card, image, table, testimonials, hero, and sidebar components. Co-Authored-By: Claude Fable 5 --- layouts/_partials/utilities/Args.html | 6 +- layouts/_partials/utilities/ArgsSchema.html | 36 ++- layouts/_partials/utilities/InitArgs.html | 273 +++----------------- tests/golden/bookshop.json | 50 +++- tests/golden/casting.json | 39 +-- tests/golden/defaults.json | 20 +- tests/golden/envelope.json | 7 +- tests/golden/frontmatter.json | 5 +- tests/golden/nesting.json | 59 +++-- tests/golden/positional.json | 4 +- tests/golden/required.json | 7 +- tests/golden/schema.json | 11 - 12 files changed, 186 insertions(+), 331 deletions(-) diff --git a/layouts/_partials/utilities/Args.html b/layouts/_partials/utilities/Args.html index 5122789..77cc201 100644 --- a/layouts/_partials/utilities/Args.html +++ b/layouts/_partials/utilities/Args.html @@ -169,7 +169,11 @@ {{ $min := string (or $node.options.min "-") }} {{ $max := string (or $node.options.max "-") }} {{ $msg := printf "[%s] argument '%s': value '%v' out of range [%s, %s]" $name $path $value $min $max }} - {{ if or (gt $depth 0) (not $value) }} + {{/* unlike the type check above, legacy's numeric range check had no falsy + exemption (it errors on 0 just as it does on any other out-of-range + value) — only recursion depth (a newly-detectable capability) downgrades + this finding; adjudicated in the Task 7/8 handoff (see progress ledger). */}} + {{ if gt $depth 0 }} {{ $newmsg = $newmsg | append $msg }} {{ else }} {{ $errmsg = $errmsg | append $msg }} diff --git a/layouts/_partials/utilities/ArgsSchema.html b/layouts/_partials/utilities/ArgsSchema.html index f22771c..c134062 100644 --- a/layouts/_partials/utilities/ArgsSchema.html +++ b/layouts/_partials/utilities/ArgsSchema.html @@ -47,7 +47,27 @@ {{/* merge the global definition with inline overrides from the structure file or blueprint */}} {{ $def := index $arguments $normKey | default dict }} {{ if reflect.IsMap $val }} - {{ $def = merge $def $val }} + {{ if and (isset $val "type") $def.type (ne $val.type $def.type) }} + {{/* the local override redeclares "type" as something other than the global + definition's type: this is a same-named-but-unrelated argument (e.g. a + deprecated component-local "size" enum shadowing the global heading "size" + int/range, or a component-local "data" dict shadowing the global "data" file + path string). Hugo's merge deep-merges nested maps, so layering the override + over the global def would leak fields whose value is only meaningful for the + old type — "default" and "config" (what value fills the gap when absent) and + "options" (enum/range rules) — into the new one. Drop those three before + merging; harmless metadata such as "comment" is still safe (and useful) to + inherit. */}} + {{ $base := dict }} + {{ range $k, $v := $def }} + {{ if not (in (slice "default" "config" "options") $k) }} + {{ $base = merge $base (dict $k $v) }} + {{ end }} + {{ end }} + {{ $def = merge $base $val }} + {{ else }} + {{ $def = merge $def $val }} + {{ end }} {{ else if and (ne $val nil) (not (reflect.IsSlice $val)) }} {{ $def = merge $def (dict "default" $val) }} {{ end }} @@ -68,7 +88,11 @@ {{ $accepts = $accepts | append "string" }} {{ else if eq $type "bool" }} {{ $accepts = $accepts | append "bool" }} - {{ else if in (slice "int" "int64") $type }} + {{ else if in (slice "int" "int8" "int16" "int32" "int64" "uint" "uint8" "uint16" "uint32" "uint64") $type }} + {{/* mirror inline/value-kind.html's "int" kind set: every one of these raw Go + integer type names is used verbatim as a declared type somewhere in + _arguments.yml (e.g. icon's 'uint64'), and legacy matched them via a plain + printf "%T" comparison with no allowlist at all */}} {{ $accepts = $accepts | append "int" }} {{ else if in (slice "float" "float64") $type }} {{ $accepts = $accepts | append "float" "int" }} @@ -82,9 +106,11 @@ {{ else }} {{ $shape := index $typesData $type }} {{ if eq $shape nil }} - {{ if strings.Contains $type "." }} - {{/* package-qualified Go reflect-type name (e.g. template.HTML): pass through - verbatim for a printf "%T" match at validation time, as legacy did */}} + {{/* opaque Go reflect-type name (e.g. template.HTML, []string, *source.File, + map[string]interface {}): pass through verbatim for a printf "%T" match at + validation time, as legacy did. A plain bareword (no dot/bracket/asterisk) + is never a valid Go type expression, so it is a genuine unknown-type error. */}} + {{ if or (strings.Contains $type ".") (strings.Contains $type "[") (strings.Contains $type "*") }} {{ $reflects = $reflects | append $type }} {{ else }} {{ $errmsg = $errmsg | append (printf "schema: unknown type '%s' for '%s'" $type $path) }} diff --git a/layouts/_partials/utilities/InitArgs.html b/layouts/_partials/utilities/InitArgs.html index 594ea61..a1ff201 100644 --- a/layouts/_partials/utilities/InitArgs.html +++ b/layouts/_partials/utilities/InitArgs.html @@ -1,46 +1,15 @@ -{{ define "_partials/inline/default.html" }} - {{ $config := split .config "." }} - {{ $default := .default }} - {{ return or (index site.Params $config) $default }} -{{ end }} - -{{ define "_partials/inline/alias-type.html" }} - {{ $type := .type }} - {{ $custom := .types }} - {{ $references := .references }} - {{ $aliases := dict "path" "string" "select" "string" "url" "string" "dict" "[]map[string]interface {}" "slice" "[]interface {}" }} - {{ $reserved := slice "bool" "int" "int64" "float" "float64" "string" "dict" "slice" }} - - {{ $input := slice }} - {{ if not $type }} - {{ errorf "expected type argument: %s" page.File }} - {{ else }} - {{ $input = slice | append $type }} - {{ end }} - - {{ $extra := slice }} - {{ range $element := $input }} - {{ $alias := index $aliases $element }} - {{ if $alias }}{{ $extra = $extra | append slice $alias }}{{ end }} - {{ if not (in $reserved $element) }} - {{ $def := index $references $element }} - {{ if $def }} - {{ $extra = $extra | append slice $def._reflect }} - {{ end }} - {{ end }} - {{ if $extra }}{{ $input = $input | append $extra }}{{ end }} - {{ end }} - - {{ return $input }} -{{ end }} +{{/* + Compatibility shim over utilities/Args.html. Preserves the legacy flat return map: argument + values under their declared names plus camelCase duplicates, merged with the bookkeeping keys + err, errmsg, warnmsg, and default. New code should call utilities/Args.html directly. +*/}} -{{/* Initialize arguments */}} {{ $structure := .structure }} {{ $bookshop := .bookshop }} {{ $child := .child }} @@ -48,223 +17,47 @@ {{ $args := .args | default dict }} {{ $group := .group }} -{{/* Initialize local variables */}} {{ $error := false }} {{ $errmsg := slice }} {{ $warnmsg := slice }} {{ $params := dict }} -{{ $types := dict }} {{ $default := slice }} -{{/* Validate partial arguments */}} {{ if and (not $structure) (not $bookshop) }} - {{- $errmsg = $errmsg | append (printf "partial [utilities/InitArgs.html] - Missing value for param 'structure' or 'bookshop'") -}} + {{ $errmsg = $errmsg | append (printf "partial [utilities/InitArgs.html] - Missing value for param 'structure' or 'bookshop'") }} {{ $error = true }} -{{ end }} - -{{/* Initialize type structure */}} -{{ if hasPrefix $structure "bookshop-" }}{{ $bookshop = strings.TrimPrefix "bookshop-" $structure }}{{ $structure = "" }}{{ end }} -{{ if not $error }} - {{ $types = partial "utilities/InitTypes.html" (dict "structure" $structure "bookshop" $bookshop "child" $child ) }} - {{ if $types.errmsg }}{{ $errmsg = $errmsg | append $types.errmsg }}{{ $error = $types.err }}{{ end }} - {{ if $types.warnmsg }}{{ $warnmsg = $warnmsg | append $types.warnmsg }}{{ end }} -{{ end }} - -{{ $namedargs := dict }} -{{ if not $named }} - {{ range $index, $val := $args }} - {{ $found := false }} - {{ range $k, $v := $types.types }} - {{ if eq $index $v.position }} - {{ $namedargs = merge $namedargs (dict $k $val) }} - {{ $found = true }} - {{ break }} - {{ end }} - {{ end }} - {{ if not $found }} - {{ $errmsg = $errmsg | append (printf "[%s] unsupported argument at index %d (value: '%s')" (or $structure $bookshop) $index $val) }} - {{ end }} - {{ end }} {{ else }} - {{ $namedargs = $args }} -{{ end }} - -{{/* Validate passed arguments and initialize their default value when applicable */}} -{{ if not $error }} - {{ range $key, $val := $namedargs }} - {{ $def := index $types.types $key }} - - {{/* Bookshop-only: normalize snake_case ↔ kebab-case argument keys */}} - {{ if and $bookshop (ne $key "_default") }} - {{ if not $def }} - {{/* Key not found — try the alternate casing (snake↔kebab). */}} - {{ $match := findRESubmatch "^(_*)(.*)$" $key }} - {{ $prefix := index $match 0 1 }} - {{ $body := index $match 0 2 }} - {{ $altKey := "" }} - {{ if strings.Contains $body "_" }} - {{/* snake_case input: try kebab-case lookup (v5 InitTypes stores kebab keys) */}} - {{ $altKey = print $prefix (replaceRE "_" "-" $body) }} - {{ else if strings.Contains $body "-" }} - {{/* kebab-case input: try snake_case lookup (v4 InitTypes stores snake keys); warn. */}} - {{ $altKey = print $prefix (replaceRE "-" "_" $body) }} - {{ end }} - {{ if $altKey }} - {{ with index $types.types $altKey }} - {{ $def = . }} - {{/* Warn only when the caller used kebab-case (snake_case is preferred). */}} - {{ if strings.Contains $key "-" }} - {{ $warnmsg = $warnmsg | append (printf "[%s] argument '%s': prefer snake_case '%s'" $bookshop $key (replaceRE "-" "_" $key)) }} - {{ end }} - {{ end }} - {{ end }} - {{ else if strings.Contains $key "-" }} - {{/* Kebab-case key found directly (v5 InitTypes) — warn to use snake_case instead. */}} - {{ $warnmsg = $warnmsg | append (printf "[%s] argument '%s': prefer snake_case '%s'" $bookshop $key (replaceRE "-" "_" $key)) }} - {{ end }} - {{ end }} - - {{ if eq $key "_default" }} - {{ $default = $default | append $val }} - {{ else if not $def }} - {{ if eq (printf "%T" $key) "string" }} - {{ $errmsg = $errmsg | append (printf "[%s] unsupported argument '%s'" (or $structure $bookshop) $key) }} - {{ else if eq (printf "%T" $key) "int" }} - {{ $errmsg = $errmsg | append (printf "[%s] unsupported argument at index %d (value: '%s')" (or $structure $bookshop) $key $val) }} - {{ else }} - {{ $errmsg = $errmsg | append (printf "[%s] unsupported argument value '%v'" (or $structure $bookshop) $val) }} - {{ end }} - {{ $error = true }} - {{ break }} - {{ else }} - {{/* initialize default value */}} - {{ if and (eq $val nil) (or $def.config $def.default) }} - {{ $val = (partial "inline/default.html" (dict "config" $def.config "default" $def.default)) }} - {{ $default = $default | append $key }} - {{ end }} - - {{/* validate type */}} - {{ $expected := partial "inline/alias-type.html" (dict "type" $def.type "types" $types.types "references" $types.udt) }} - {{ $actual := printf "%T" $val }} - - {{/* cast supported types from/to string */}} - {{ if and (in $expected "bool") (in (slice "true" "false") $val) }} - {{ $actual = "bool" }} - {{ $val = cond (eq $val "true") true false }} - {{ else if and (in $expected "int") (findRE `^-?\d+$` $val) }} - {{ $actual = "int" }} - {{ $val = int $val }} - {{ else if and (in $expected "float") (findRE `^(?:[1-9]\d*|0)?(?:\.\d+)?$` $val) }} - {{ $actual = "float" }} - {{ $val = float $val }} - {{ else if and (in $expected "string") (in (slice "bool" "int" "int64" "float" "float64") $actual) }} - {{ $actual = "string" }} - {{ $val = string $val }} - {{ end }} - - {{ if and $val (not (in $expected $actual)) }} - {{ $errmsg = $errmsg | append (printf "[%s] argument '%s': expected type '%s', got '%s' with value '%v'" (or $structure $bookshop) (string $key) (delimit $expected ", ") $actual $val) }} - {{ $error = true }} - {{ break }} - {{ end }} - - {{/* validate permitted values */}} - {{ if and (reflect.IsMap $def.options) $def.options.values (eq $actual "string") }} - {{ if and $val (not (in $def.options.values $val)) }} - {{ $errmsg = $errmsg | append (printf "[%s] argument '%s': unexpected value '%s'" (or $structure $bookshop) (string $key) $val) }} - {{ $error = true }} - {{ break }} - {{ end }} - {{ else if and (reflect.IsMap $def.options) (or $def.options.min $def.options.max) (in (slice "int" "float" "float64") $actual) }} - {{ if or - (and $def.options.min (lt $val $def.options.min)) - (and $def.options.max (gt $val $def.options.max)) - }} - {{ $min := (string (or $def.options.min "-")) }} - {{ $max := (string (or $def.options.max "-")) }} - {{ $errmsg = $errmsg | append (printf "[%s] argument '%s': value '%s' out of range [%s, %s]" (or $structure $bookshop) (string $key) (string $val) $min $max) }} - {{ $error = true }} - {{ break }} - {{ end }} - {{ end }} - - {{/* validate if argument is deprecated */}} - {{ with $def.deprecated }} - {{ $warn := printf "[%s] argument '%s': deprecated in v%s" (or $structure $bookshop) $key (strings.TrimPrefix "v" .) }} - {{ with $def.alternative }} - {{ $warn = printf "%s, use '%s' instead" $warn . }} - {{ end }} - {{ $warnmsg = $warnmsg | append $warn }} - {{ end }} - {{ end }} - - {{/* append the argument to the return set */}} - {{ if not $error }} - {{ $params = merge $params (dict $key $val) }} - {{ end }} + {{ if hasPrefix $structure "bookshop-" }} + {{ $bookshop = strings.TrimPrefix "bookshop-" $structure }} + {{ $structure = "" }} {{ end }} -{{ end }} -{{ if not $error }} - {{/* validate required arguments */}} - {{ $max := len $namedargs }} - {{ $expected := 0 }} - {{ range $key, $val := $types.types }} - {{ $skip := false }} - {{ $groups := slice | append $val.group }} - {{ if and $group $val.group }} - {{ $skip = not (in $groups $group )}} - {{ end }} - - {{ if and (not $skip) (not $val.optional) }} - {{ if not (isset $namedargs $key) }} - {{ $errmsg = $errmsg | append (printf "[%s] argument '%s': expected value" (or $structure $bookshop) $key) }} - {{ $error = true }} - {{ end }} - {{ end }} - {{ end }} - - {{ if lt $max $expected }} - {{ $errmsg = $errmsg | append (printf "[%s] expected '%d' args, got '%d'" (or $structure $bookshop) $expected $max) }} - {{ $error = true }} - {{ end }} - - {{/* add missing keys with default values (nested one level deep) or empty slice */}} - {{ range $key, $val := $types.types }} - {{ $check := index $params $key }} - {{ if eq $check nil }} - {{ $udt := index $types.udt $key }} - {{ $check1 := index $val "config" }} - {{ $check2 := index $val "default" }} - {{ if or $check1 $check2 }} - {{ $params = merge $params (dict - $key (partial "inline/default.html" (dict "config" $val.config "default" $val.default)) - ) }} - {{ $default = $default | append $key }} - {{ else if $udt }} - {{ if eq $udt._reflect "[]interface {}" }} - {{ $params = merge $params (dict $key slice) }} - {{ else }} - {{ $nested := dict }} - {{ range $k, $v := $udt }} - {{ if and (reflect.IsMap $v) (or $v.config $v.default) }} - {{ $nested = merge $nested (dict - $k (partial "inline/default.html" (dict "config" $val.config "default" $val.default)) - )}} - {{ end }} - {{ end }} - {{ $params = merge $params (dict $key $nested) }} + {{ $res := partial "utilities/Args.html" (dict + "structure" $structure + "bookshop" $bookshop + "child" $child + "args" $args + "named" $named + "group" $group + "strict" false + ) }} + {{ $error = $res.err }} + {{ $errmsg = $res.errmsg }} + {{ $warnmsg = $res.warnmsg }} + + {{ if not $error }} + {{ $compiled := partial "utilities/ArgsSchema.html" (dict "structure" $structure "bookshop" $bookshop "child" $child) }} + {{ range $key, $node := $compiled.schema }} + {{ $val := index $res.args $node.camelKey }} + {{ if ne $val nil }} + {{ $params = merge $params (dict $key $val) }} + {{ if ne $node.camelKey $key }} + {{ $params = merge $params (dict $node.camelKey $val) }} {{ end }} {{ end }} {{ end }} - {{ end }} - - {{/* add the key-value pair using camel case to support chaining of the identifier */}} - {{/* see https://gohugo.io/configuration/params/#article */}} - {{ range $key, $val := $params }} - {{ if strings.Contains $key "-" }} - {{ $camelKey := partial "utilities/camelize.html" $key }} - {{ $params = merge $params (dict $camelKey $val) }} + {{ range $res.defaulted }} + {{ if not (strings.Contains . ".") }}{{ $default = $default | append . }}{{ end }} {{ end }} {{ end }} {{ end }} diff --git a/tests/golden/bookshop.json b/tests/golden/bookshop.json index 2c0298d..180a074 100644 --- a/tests/golden/bookshop.json +++ b/tests/golden/bookshop.json @@ -12,14 +12,22 @@ "initargs": { "default": [ "link_type", + "show_more", "width" ], "err": false, "errmsg": [], "heading": { - "title": "Hello" + "align": "start", + "arrangement": "above", + "size": 4, + "title": "Hello", + "width": 8 }, + "linkType": "button", "link_type": "button", + "showMore": false, + "show_more": false, "warnmsg": [], "width": 8 } @@ -53,16 +61,11 @@ "warnmsg": [] }, "initargs": { - "_bookshop_name": "test-hero", "default": [], "err": true, "errmsg": [ "[test-hero] argument 'width': value '0' out of range [1, 12]" ], - "heading": { - "title": "Hello" - }, - "show_more": false, "warnmsg": [] } }, @@ -96,20 +99,27 @@ "warnmsg": [] }, "initargs": { + "_bookshopName": "test-hero", "_bookshop_name": "test-hero", "_ordinal": 0, "default": [ "link_type", + "show_more", "width" ], "err": false, "errmsg": [], "heading": { - "align": null, - "title": "Hello" + "align": "start", + "arrangement": "above", + "size": 4, + "title": "Hello", + "width": 8 }, + "linkType": "button", "link_type": "button", - "show_more": null, + "showMore": false, + "show_more": false, "warnmsg": [], "width": 8 } @@ -144,17 +154,22 @@ }, "initargs": { "default": [ - "link_type", + "show_more", "width" ], "err": false, "errmsg": [], "heading": { - "title": "Hello" + "align": "start", + "arrangement": "above", + "size": 4, + "title": "Hello", + "width": 8 }, - "link-type": "link", "linkType": "link", - "link_type": "button", + "link_type": "link", + "showMore": false, + "show_more": false, "warnmsg": [ "[test-hero] argument 'link-type': prefer snake_case 'link_type'" ], @@ -188,6 +203,7 @@ "warnmsg": [] }, "initargs": { + "_bookshopName": "test-hero", "_bookshop_name": "test-hero", "default": [ "width" @@ -195,9 +211,15 @@ "err": false, "errmsg": [], "heading": { - "title": "Hello" + "align": "start", + "arrangement": "above", + "size": 4, + "title": "Hello", + "width": 8 }, + "linkType": "link", "link_type": "link", + "showMore": true, "show_more": true, "warnmsg": [], "width": 8 diff --git a/tests/golden/casting.json b/tests/golden/casting.json index 3a6fb24..7a7497d 100644 --- a/tests/golden/casting.json +++ b/tests/golden/casting.json @@ -33,9 +33,11 @@ "default": [], "err": false, "errmsg": [], - "ratio-value": 0, - "ratioValue": 0, - "warnmsg": [] + "ratio-value": "", + "ratioValue": "", + "warnmsg": [ + "[test-cast] argument 'ratio-value': expected type 'float', got 'string' with value ''" + ] } }, "falsy-wrong-type-skips-validation": { @@ -55,7 +57,9 @@ "default": [], "err": false, "errmsg": [], - "warnmsg": [] + "warnmsg": [ + "[test-cast] argument 'count': expected type 'int', got 'bool' with value 'false'" + ] } }, "generic-dict-as-map": { @@ -71,11 +75,12 @@ "warnmsg": [] }, "initargs": { + "data": { + "anything": "goes" + }, "default": [], - "err": true, - "errmsg": [ - "[test-cast] argument 'data': expected type 'dict, []map[string]interface {}', got 'map[string]interface {}' with value 'map[anything:goes]'" - ], + "err": false, + "errmsg": [], "warnmsg": [] } }, @@ -94,11 +99,14 @@ "warnmsg": [] }, "initargs": { - "default": [], - "err": true, - "errmsg": [ - "[test-cast] argument 'data': expected type 'dict, []map[string]interface {}', got '[]interface {}' with value '[map[anything:goes]]'" + "data": [ + { + "anything": "goes" + } ], + "default": [], + "err": false, + "errmsg": [], "warnmsg": [] } }, @@ -145,10 +153,9 @@ }, "initargs": { "default": [], - "err": true, - "errmsg": [ - "[test-cast] argument 'label': expected type 'string', got 'uint64' with value '7'" - ], + "err": false, + "errmsg": [], + "label": "7", "warnmsg": [] } }, diff --git a/tests/golden/defaults.json b/tests/golden/defaults.json index f056c3c..4c765f4 100644 --- a/tests/golden/defaults.json +++ b/tests/golden/defaults.json @@ -30,11 +30,11 @@ "err": false, "errmsg": [], "from-config": "from-site-params", - "from-config-false": true, + "from-config-false": false, "from-config-missing": "static-fallback", "from-config-true": true, "fromConfig": "from-site-params", - "fromConfigFalse": true, + "fromConfigFalse": false, "fromConfigMissing": "static-fallback", "fromConfigTrue": true, "static-label": "fallback", @@ -73,11 +73,11 @@ "err": false, "errmsg": [], "from-config": "from-site-params", - "from-config-false": true, + "from-config-false": false, "from-config-missing": "static-fallback", "from-config-true": true, "fromConfig": "from-site-params", - "fromConfigFalse": true, + "fromConfigFalse": false, "fromConfigMissing": "static-fallback", "fromConfigTrue": true, "static-label": "fallback", @@ -119,11 +119,11 @@ "err": false, "errmsg": [], "from-config": "from-site-params", - "from-config-false": true, + "from-config-false": false, "from-config-missing": "static-fallback", "from-config-true": true, "fromConfig": "from-site-params", - "fromConfigFalse": true, + "fromConfigFalse": false, "fromConfigMissing": "static-fallback", "fromConfigTrue": true, "legacy-arg": "old", @@ -166,11 +166,11 @@ "err": false, "errmsg": [], "from-config": "from-site-params", - "from-config-false": true, + "from-config-false": false, "from-config-missing": "static-fallback", "from-config-true": true, "fromConfig": "from-site-params", - "fromConfigFalse": true, + "fromConfigFalse": false, "fromConfigMissing": "static-fallback", "fromConfigTrue": true, "static-label": "fallback", @@ -207,11 +207,11 @@ "err": false, "errmsg": [], "from-config": "from-site-params", - "from-config-false": true, + "from-config-false": false, "from-config-missing": "static-fallback", "from-config-true": true, "fromConfig": "from-site-params", - "fromConfigFalse": true, + "fromConfigFalse": false, "fromConfigMissing": "static-fallback", "fromConfigTrue": true, "static-label": "custom", diff --git a/tests/golden/envelope.json b/tests/golden/envelope.json index 3063860..5619eb6 100644 --- a/tests/golden/envelope.json +++ b/tests/golden/envelope.json @@ -2,12 +2,9 @@ "default-arg-collision": { "args": { "args": { - "default": "user-value", - "plain": "false" + "default": "user-value" }, - "defaulted": [ - "plain" - ], + "defaulted": [], "err": false, "errmsg": [], "warnmsg": [] diff --git a/tests/golden/frontmatter.json b/tests/golden/frontmatter.json index 5c9ce44..adf7fac 100644 --- a/tests/golden/frontmatter.json +++ b/tests/golden/frontmatter.json @@ -26,7 +26,10 @@ "errmsg": [], "heading": { "align": "center", - "title": "Hello" + "arrangement": "above", + "size": 4, + "title": "Hello", + "width": 8 }, "locations": [], "warnmsg": [] diff --git a/tests/golden/nesting.json b/tests/golden/nesting.json index 4f8fd77..6ca55c7 100644 --- a/tests/golden/nesting.json +++ b/tests/golden/nesting.json @@ -25,10 +25,10 @@ "err": false, "errmsg": [], "heading": { - "align": null, - "arrangement": null, - "size": null, - "width": null + "align": "start", + "arrangement": "above", + "size": 4, + "width": 8 }, "locations": [], "warnmsg": [] @@ -72,10 +72,10 @@ "err": false, "errmsg": [], "heading": { - "align": null, - "arrangement": null, - "size": null, - "width": null + "align": "start", + "arrangement": "above", + "size": 4, + "width": 8 }, "locations": [ { @@ -85,6 +85,7 @@ "title": "Parking" } ], + "mode": false, "title": "Office" } ], @@ -118,7 +119,10 @@ "errmsg": [], "heading": { "align": "center", - "title": "Hello" + "arrangement": "above", + "size": 4, + "title": "Hello", + "width": 8 }, "locations": [], "warnmsg": [] @@ -151,9 +155,11 @@ "err": false, "errmsg": [], "heading": { - "align": null, - "preheading": null, - "title": "Hello" + "align": "start", + "arrangement": "above", + "size": 4, + "title": "Hello", + "width": 8 }, "locations": [], "warnmsg": [] @@ -189,11 +195,17 @@ "err": false, "errmsg": [], "heading": { + "align": "start", + "arrangement": "above", + "size": 4, "title": "Hello", - "typo-key": "oops" + "typo-key": "oops", + "width": 8 }, "locations": [], - "warnmsg": [] + "warnmsg": [ + "[test-nested] argument 'heading': unsupported attribute 'typo-key'" + ] } }, "nested-wrong-type-passes": { @@ -224,11 +236,16 @@ "err": false, "errmsg": [], "heading": { - "align": 42, - "title": "Hello" + "align": "42", + "arrangement": "above", + "size": 4, + "title": "Hello", + "width": 8 }, "locations": [], - "warnmsg": [] + "warnmsg": [ + "[test-nested] argument 'heading.align': unexpected value '42'" + ] } }, "udt-list-empty": { @@ -257,10 +274,10 @@ "err": false, "errmsg": [], "heading": { - "align": null, - "arrangement": null, - "size": null, - "width": null + "align": "start", + "arrangement": "above", + "size": 4, + "width": 8 }, "locations": [], "warnmsg": [] diff --git a/tests/golden/positional.json b/tests/golden/positional.json index c79154d..538ff6d 100644 --- a/tests/golden/positional.json +++ b/tests/golden/positional.json @@ -34,12 +34,10 @@ }, "initargs": { "default": [], - "err": false, + "err": true, "errmsg": [ "[test-required] unsupported argument at index 2 (value: 'gamma')" ], - "kind": "beta", - "name": "alpha", "warnmsg": [] } }, diff --git a/tests/golden/required.json b/tests/golden/required.json index d36c2ba..a47fda8 100644 --- a/tests/golden/required.json +++ b/tests/golden/required.json @@ -15,7 +15,9 @@ "default": [], "err": true, "errmsg": [ - "[test-required] unsupported argument 'bogus-one'" + "[test-required] unsupported argument 'bogus-one'", + "[test-required] unsupported argument 'bogus-two'", + "[test-required] argument 'name': expected value" ], "warnmsg": [] } @@ -56,7 +58,6 @@ "errmsg": [ "[test-required] argument 'partial-only': expected value" ], - "name": "alpha", "warnmsg": [] } }, @@ -78,7 +79,6 @@ "errmsg": [ "[test-required] argument 'partial-only': expected value" ], - "name": "alpha", "warnmsg": [] } }, @@ -100,7 +100,6 @@ "errmsg": [ "[test-required] argument 'name': expected value" ], - "flag": true, "warnmsg": [] } }, diff --git a/tests/golden/schema.json b/tests/golden/schema.json index 42d9e1a..e574631 100644 --- a/tests/golden/schema.json +++ b/tests/golden/schema.json @@ -279,17 +279,6 @@ "kind": "scalar", "name": "ratio", "optional": true, - "options": { - "values": [ - "1x1", - "3x1", - "3x2", - "4x3", - "16x9", - "21x9", - "auto" - ] - }, "parent": "merge", "types": [ "string" From 48f773f867000849a1bd9ca5e6f0a62e7ee21d7d Mon Sep 17 00:00:00 2001 From: Mark Dumay <61946753+markdumay@users.noreply.github.com> Date: Sun, 12 Jul 2026 10:25:05 +0200 Subject: [PATCH 15/21] fix: classify excess positional arguments as newly detectable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Coordinator adjudication overruled the planned err-flip on excess positional arguments: the spec's acceptance criterion requires zero new errors through the shim path on the Hinode exampleSite, and warnings-first governs any newly-enforced failure class. Legacy surfaced the "unsupported argument at index %d" message but never gated on it (err stayed false, mapped args stayed populated), so enforcing it now would be a behavioral break. The positional-mapping loop in Args.html appends the finding to newmsg instead of errmsg: strict mode promotes it to a real error for clean-API callers, while the InitArgs shim keeps err false with args populated and the message in warnmsg — legacy-faithful except the message slice, resolving legacy's self-contradictory errmsg-with-err:false shape. Golden delta is confined to positional/excess-position-errors: the initargs half reverts to err false with name/kind populated and the message in warnmsg; the strict args half keeps err true. Hinode exampleSite smoke test now builds with zero ERROR lines: the 45 mod-fontawesome fas/fab/icon errors are gone (23 became warnings with unchanged text, the 21 secondary "Missing icon name" failures vanished since icon args stay populated). The distinct warning-message set is identical to the previous run. Co-Authored-By: Claude Fable 5 --- layouts/_partials/utilities/Args.html | 5 ++++- tests/golden/positional.json | 10 ++++++---- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/layouts/_partials/utilities/Args.html b/layouts/_partials/utilities/Args.html index 77cc201..a0e4987 100644 --- a/layouts/_partials/utilities/Args.html +++ b/layouts/_partials/utilities/Args.html @@ -311,7 +311,10 @@ {{ with index $compiled.positions (string $index) }} {{ $provided = merge $provided (dict . $val) }} {{ else }} - {{ $errmsg = $errmsg | append (printf "[%s] unsupported argument at index %d (value: '%s')" $name $index $val) }} + {{/* newly-detectable class (warnings-first): legacy surfaced this message but + never gated on it — err stayed false and the mapped args stayed populated. + Strict mode promotes it to an error; the shim keeps it a warning. */}} + {{ $newmsg = $newmsg | append (printf "[%s] unsupported argument at index %d (value: '%s')" $name $index $val) }} {{ end }} {{ end }} {{ end }} diff --git a/tests/golden/positional.json b/tests/golden/positional.json index 538ff6d..3e33014 100644 --- a/tests/golden/positional.json +++ b/tests/golden/positional.json @@ -34,11 +34,13 @@ }, "initargs": { "default": [], - "err": true, - "errmsg": [ + "err": false, + "errmsg": [], + "kind": "beta", + "name": "alpha", + "warnmsg": [ "[test-required] unsupported argument at index 2 (value: 'gamma')" - ], - "warnmsg": [] + ] } }, "first-position-only": { From 4495934f7c10b3deef2ef14c249e70b6474ebe24 Mon Sep 17 00:00:00 2001 From: Mark Dumay <61946753+markdumay@users.noreply.github.com> Date: Sun, 12 Jul 2026 10:36:26 +0200 Subject: [PATCH 16/21] test: characterize the legacy InitTypes contract Add an inittypes golden group covering scalar, nested-UDT, bookshop-blueprint, and child-merge structures, exercised through the current legacy InitTypes implementation before it is rewired as a shim over ArgsSchema. No fixture crashes the legacy path (the union-typed 'title' field resolves via Hugo's append-flattening of slice.type into aliases, so no map is indexed by a slice key). Two legacy quirks are captured verbatim for later reconciliation: bookshop blueprint dict values (e.g. heading) leak their raw instance keys into the top-level type def, and CloudCannon 'editors' metadata leaks through on nested UDT members that declare it. Co-Authored-By: Claude Fable 5 --- exampleSite/content/tests/inittypes.md | 4 + exampleSite/data/tests/inittypes.yml | 14 + exampleSite/layouts/tests/single.json | 6 + tests/golden/inittypes.json | 385 +++++++++++++++++++++++++ 4 files changed, 409 insertions(+) create mode 100644 exampleSite/content/tests/inittypes.md create mode 100644 exampleSite/data/tests/inittypes.yml create mode 100644 tests/golden/inittypes.json diff --git a/exampleSite/content/tests/inittypes.md b/exampleSite/content/tests/inittypes.md new file mode 100644 index 0000000..e581e55 --- /dev/null +++ b/exampleSite/content/tests/inittypes.md @@ -0,0 +1,4 @@ +--- +title: inittypes +outputs: ["json"] +--- diff --git a/exampleSite/data/tests/inittypes.yml b/exampleSite/data/tests/inittypes.yml new file mode 100644 index 0000000..63c6296 --- /dev/null +++ b/exampleSite/data/tests/inittypes.yml @@ -0,0 +1,14 @@ +cases: + - name: scalar-structure + api: inittypes + structure: test-cast + - name: nested-structure + api: inittypes + structure: test-nested + - name: bookshop-component + api: inittypes + bookshop: test-hero + - name: child-merge + api: inittypes + structure: test-stack + child: test-card diff --git a/exampleSite/layouts/tests/single.json b/exampleSite/layouts/tests/single.json index ef2923f..dd5bee1 100644 --- a/exampleSite/layouts/tests/single.json +++ b/exampleSite/layouts/tests/single.json @@ -15,6 +15,12 @@ "child" ($case.child | default "") ) -}} {{- $entry = dict "schema" $compiled -}} + {{- else if eq $case.api "inittypes" -}} + {{- $entry = dict "inittypes" (partial "utilities/InitTypes.html" (dict + "structure" ($case.structure | default "") + "bookshop" ($case.bookshop | default "") + "child" ($case.child | default "") + )) -}} {{- else -}} {{- $params := dict "structure" ($case.structure | default "") diff --git a/tests/golden/inittypes.json b/tests/golden/inittypes.json new file mode 100644 index 0000000..f117016 --- /dev/null +++ b/tests/golden/inittypes.json @@ -0,0 +1,385 @@ +{ + "bookshop-component": { + "inittypes": { + "err": false, + "errmsg": [], + "types": { + "_bookshop_name": { + "comment": "Alias for _bookshop_name.", + "group": "partial", + "optional": true, + "type": "string" + }, + "_ordinal": { + "comment": "Zero-based position of the bookshop component within the page's component hierarchy.", + "group": "partial", + "optional": true, + "type": "int" + }, + "heading": { + "align": "start", + "comment": "Heading of the content block, including a preheading and content element.", + "optional": false, + "title": null, + "type": "heading" + }, + "id": { + "comment": "Unique identifier of the current element.", + "optional": true, + "type": "string" + }, + "link_type": { + "comment": "Style of the link.", + "default": "button", + "optional": true, + "options": { + "values": [ + "button", + "link" + ] + }, + "type": "select" + }, + "show_more": { + "optional": true, + "type": "bool" + }, + "width": { + "comment": "Column width of the element. For embedded elements, the width is relative to the parent's container.", + "default": 8, + "optional": true, + "options": { + "max": 12, + "min": 1 + }, + "type": "int" + } + }, + "udt": { + "heading": { + "_reflect": "map[string]interface {}", + "align": { + "comment": "Alignment of the headline, content, or icon.", + "default": "start", + "optional": true, + "options": { + "values": [ + "start", + "center", + "end" + ] + }, + "type": "select" + }, + "arrangement": { + "comment": "Arrangement of the preheading, either left or above the header. On smaller screens, the preheading is always placed on top.", + "config": "style.title.arrangement", + "default": "above", + "optional": true, + "options": { + "values": [ + "above", + "first" + ] + }, + "type": "select" + }, + "content": { + "comment": "Section content displayed below the title.", + "editors": [ + { + "name": "cc", + "type": "textarea" + } + ], + "optional": true, + "type": [ + "string", + "template.HTML" + ] + }, + "preheading": { + "comment": "Preheading of the section heading.", + "optional": true, + "type": "string" + }, + "size": { + "comment": "Display size of the headline.", + "config": "style.title.size", + "default": 4, + "optional": true, + "options": { + "max": 6, + "min": 1 + }, + "type": "int" + }, + "title": { + "comment": "Title of the element. If the element references a (local) page, the title overrides the referenced page's title.", + "optional": true, + "type": [ + "string", + "hstring.RenderedString", + "hstring.HTML", + "template.HTML" + ] + }, + "width": { + "comment": "Column width of the element. For embedded elements, the width is relative to the parent's container.", + "default": 8, + "optional": true, + "options": { + "max": 12, + "min": 1 + }, + "type": "int" + } + } + }, + "warnmsg": [] + } + }, + "child-merge": { + "inittypes": { + "err": false, + "errmsg": [], + "types": { + "hook": { + "comment": "Render hook for the element's partial.", + "optional": true, + "parent": "cascade", + "type": "string" + }, + "ratio": { + "comment": "Ratio of the media asset. When the asset is an image, it is resized and cropped (not applicable to vector graphics). For video assets, the padding of the embedded frame is adjusted. When set to auto, the original aspect ratio is used.", + "optional": true, + "options": { + "values": [ + "1x1", + "3x1", + "3x2", + "4x3", + "16x9", + "21x9", + "auto" + ] + }, + "parent": "merge", + "type": "string" + }, + "title": { + "comment": "Title of the element. If the element references a (local) page, the title overrides the referenced page's title.", + "optional": true, + "type": "string" + } + }, + "udt": {}, + "warnmsg": [] + } + }, + "nested-structure": { + "inittypes": { + "err": false, + "errmsg": [], + "types": { + "heading": { + "comment": "Heading of the content block, including a preheading and content element.", + "optional": true, + "type": "heading" + }, + "locations": { + "comment": "Office location data.", + "optional": true, + "type": "locations" + }, + "title": { + "comment": "Title of the element. If the element references a (local) page, the title overrides the referenced page's title.", + "optional": true, + "type": "string" + } + }, + "udt": { + "heading": { + "_reflect": "map[string]interface {}", + "align": { + "comment": "Alignment of the headline, content, or icon.", + "default": "start", + "optional": true, + "options": { + "values": [ + "start", + "center", + "end" + ] + }, + "type": "select" + }, + "arrangement": { + "comment": "Arrangement of the preheading, either left or above the header. On smaller screens, the preheading is always placed on top.", + "config": "style.title.arrangement", + "default": "above", + "optional": true, + "options": { + "values": [ + "above", + "first" + ] + }, + "type": "select" + }, + "content": { + "comment": "Section content displayed below the title.", + "editors": [ + { + "name": "cc", + "type": "textarea" + } + ], + "optional": true, + "type": [ + "string", + "template.HTML" + ] + }, + "preheading": { + "comment": "Preheading of the section heading.", + "optional": true, + "type": "string" + }, + "size": { + "comment": "Display size of the headline.", + "config": "style.title.size", + "default": 4, + "optional": true, + "options": { + "max": 6, + "min": 1 + }, + "type": "int" + }, + "title": { + "comment": "Title of the element. If the element references a (local) page, the title overrides the referenced page's title.", + "optional": true, + "type": [ + "string", + "hstring.RenderedString", + "hstring.HTML", + "template.HTML" + ] + }, + "width": { + "comment": "Column width of the element. For embedded elements, the width is relative to the parent's container.", + "default": 8, + "optional": true, + "options": { + "max": 12, + "min": 1 + }, + "type": "int" + } + }, + "locations": { + "_reflect": "[]interface {}", + "address": { + "comment": "Address information.", + "optional": false, + "type": "string" + }, + "anchor": { + "comment": "Anchor of the image's crop box, defaults to anchor value set in `imaging` section of the site configuration (usually `Smart`).", + "optional": true, + "options": { + "values": [ + "TopLeft", + "Top", + "TopRight", + "Left", + "Center", + "Right", + "BottomLeft", + "Bottom", + "BottomRight", + "Smart" + ] + }, + "type": "select" + }, + "image": { + "comment": "Image to include in the content block or section heading.", + "editors": [ + { + "name": "cc", + "type": "image" + } + ], + "optional": true, + "type": "string" + }, + "instructions": { + "comment": "Instructions how to reach a location using a specific transport method.", + "optional": true, + "type": "instructions" + }, + "mode": { + "comment": "Flag indicating if the media asset should support color modes. If set, the element searches for images having a matching color-mode suffix such as `-light` or `-dark`.", + "default": false, + "optional": true, + "type": "bool" + }, + "phone": { + "comment": "Phone number.", + "optional": true, + "type": "string" + }, + "title": { + "comment": "Title of the element. If the element references a (local) page, the title overrides the referenced page's title.", + "optional": true, + "type": [ + "string", + "hstring.RenderedString", + "hstring.HTML", + "template.HTML" + ] + } + } + }, + "warnmsg": [] + } + }, + "scalar-structure": { + "inittypes": { + "err": false, + "errmsg": [], + "types": { + "count": { + "optional": true, + "type": "int" + }, + "data": { + "comment": "Path of the input data relative to the site's data folder. Supported data formats include `JSON`, `TOML`, `YAML`, and `XML`. You can omit the file extension.", + "optional": true, + "type": "dict" + }, + "flag": { + "optional": true, + "type": "bool" + }, + "items-list": { + "optional": true, + "type": "slice" + }, + "label": { + "comment": "Assistive label of the element.", + "optional": true, + "type": "string" + }, + "ratio-value": { + "optional": true, + "type": "float" + } + }, + "udt": {}, + "warnmsg": [] + } + } +} \ No newline at end of file From 2c64ed8fc081cdb657970567473efe94e909032c Mon Sep 17 00:00:00 2001 From: Mark Dumay <61946753+markdumay@users.noreply.github.com> Date: Sun, 12 Jul 2026 10:45:06 +0200 Subject: [PATCH 17/21] refactor: rewire InitTypes as a compatibility shim over ArgsSchema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the 156-line legacy layouts/_partials/utilities/InitTypes.html with the ~50-line shim over ArgsSchema.html, reconstructing the legacy {types, udt, err, errmsg, warnmsg} contract from schema nodes. types values use the node's declared type list, collapsing to a plain string when there is exactly one (matches assets/args.html's `index $types $val.type` lookup); udt is keyed by top-level UDT type name only, carrying `_reflect` computed from node.kind (matches legacy's printf "%T" values for dict vs list UDTs). Every changed inittypes golden line falls into one of four reconciled categories: - bookshop blueprint dict values (e.g. heading) no longer leak their raw instance keys (align, title) into the top-level type def — a legacy quirk from merging the raw blueprint value into the def; assets/args.html never reads these extra keys, so this is inert to the verified consumer. - CloudCannon 'editors' metadata no longer leaks through on nested UDT members that declare it, for the same reason (never read by args.html). - show_more (bookshop-component) gains a "default": false it previously lost — this is the sidecar-default fix already characterized in ArgsSchema.html (Task 6). - ratio (child-merge) loses its inherited "options.values" — the child structure overrides the global argument's type (select -> string), so the type-collision clean-slate merge (Task 8 fix #4) correctly drops the now-inapplicable enum constraint. Verified udt.heading._reflect == "map[string]interface {}" and udt.locations._reflect == "[]interface {}" in the nested-structure case. Hinode exampleSite smoke test (HUGO_MODULE_REPLACEMENTS pointing at this checkout): build succeeds, exit 0, zero ERROR lines. Raw WARN line counts and per-page attributions are non-deterministic build-cache/parallelism noise (confirmed by running the unchanged shim twice and observing different counts/attributions both times); the set of 87 distinct warning message templates is byte-identical across the pre-shim baseline and two post-shim runs. No warning or error mentions args.html. Co-Authored-By: Claude Fable 5 --- layouts/_partials/utilities/InitTypes.html | 179 +++++---------------- tests/golden/inittypes.json | 32 +--- 2 files changed, 37 insertions(+), 174 deletions(-) diff --git a/layouts/_partials/utilities/InitTypes.html b/layouts/_partials/utilities/InitTypes.html index 198dcc6..0828f0e 100644 --- a/layouts/_partials/utilities/InitTypes.html +++ b/layouts/_partials/utilities/InitTypes.html @@ -1,156 +1,49 @@ - -{{/* Inline partial to retrieve the type definition of the provided key (without recursion) */}} -{{ define "_partials/inline/type-definition.html" }} - {{/* Convert key name from snake case to kebab case, except for leading underscores. */}} - {{/* Examples: */}} - {{/* - "link_type" → "link-type" */}} - {{/* - "_link_type" → "_link-type" */}} - {{/* - "__link_type" → "__link-type" */}} - {{ $match := findRESubmatch "^(_*)(.*)$" .key }} - {{ $key := print (index $match 0 1) (replaceRE "_" "-" (index $match 0 2)) }} - {{ $val := .val }} - {{ $arguments := .arguments }} - {{ $types := .types }} - - {{ $def := index $arguments $key }} - {{ $udt := "" }} - {{ $reflect := "" }} - {{ $reserved := slice "bool" "int" "int64" "float" "float64" "string" "dict" "slice" }} - {{ $errorMsg := slice }} - - {{ if and $def $def.type }} - {{ $aliases := slice | append $def.type }} - {{ range $alias := $aliases }} - {{ with index $types $alias }} - - {{ $args := slice }} - {{ $reflect = printf "%T" . }} - {{ if reflect.IsMap . }} - {{ range $k, $_ := . }} - {{ $args = $args | append $k }} - {{ end }} - {{ else if reflect.IsSlice . }} - {{ with index . 0 }} - {{ range $k, $_ := . }} - {{ $args = $args | append $k }} - {{ end }} - {{ end }} - {{ end }} - - - {{ $definitions := dict }} - {{ $definitions := merge $definitions (dict "_reflect" $reflect) }} - {{ range $args }} - {{ $type := partial "inline/type-definition.html" (dict "key" . "arguments" $arguments "types" $types) }} - {{ if and $type $type.definition }} - {{ $definitions = merge $definitions (dict . $type.definition) }} - {{ else }} - {{- $errorMsg = $errorMsg | append (printf "partial [utilities/InitTypes.html] - Missing type for '%s.%s'" $key . ) -}} - {{ end }} - {{ end }} - - {{ $udt = dict $alias $definitions }} - {{ end }} - {{ end }} - {{ end }} - - {{ $merged := or $def dict }} - {{ if reflect.IsMap $val }} - {{ $merged = merge $merged $val }} - {{ else if and $val (not (reflect.IsSlice $val)) }} - {{ $merged = merge $merged (dict "default" $val) }} +{{/* + Compatibility shim over utilities/ArgsSchema.html. Reproduces the legacy return shape + {types, udt, err, errmsg, warnmsg}. Deprecated: new code should consume ArgsSchema directly. +*/}} + +{{ define "_partials/inline/legacy-def.html" }} + {{ $node := . }} + {{ $type := $node.types }} + {{ if eq (len $node.types) 1 }}{{ $type = index $node.types 0 }}{{ end }} + {{ $def := dict "type" $type "optional" $node.optional }} + {{ range $field := slice "default" "config" "options" "position" "deprecated" "alternative" "release" "parent" "comment" }} + {{ if isset $node $field }}{{ $def = merge $def (dict $field (index $node $field)) }}{{ end }} {{ end }} - {{ if not $merged.type }} - {{- $errorMsg = $errorMsg | append (printf "partial [utilities/InitTypes.html] - Missing type for '%s'" $key ) -}} + {{ with $node.group }} + {{ $group := . }} + {{ if eq (len .) 1 }}{{ $group = index . 0 }}{{ end }} + {{ $def = merge $def (dict "group" $group) }} {{ end }} - - {{ return (dict "definition" $merged "udt" $udt "errmsg" $errorMsg) }} -{{ end }} - -{{/* Initalize arguments and local variables */}} -{{ $error := false }} -{{ $errmsg := slice }} -{{ $warnmsg := slice }} -{{ $params := dict }} -{{ $definitions := dict }} -{{ $udt := dict }} - -{{ $structure := .structure }} -{{ $bookshop := .bookshop }} -{{ $group := .group }} -{{ $child := .child }} - -{{ if and (not $structure) (not $bookshop) }} - {{- $errmsg = $errmsg | append (printf "partial [utilities/InitTypes.html] - Missing value for param 'structure' or 'bookshop'") -}} - {{ $error = true }} + {{ return $def }} {{ end }} -{{/* Initalize the type structure */}} -{{ if not $error }} - {{ $args := dict }} - {{ $arguments := index (index site.Data.structures "_arguments") "arguments" }} - {{ $types := index (index site.Data.structures "_types") "types" }} - - {{/* Initalize the regular or bookshop argument structure */}} - {{ if $structure }} - {{ $args = (index site.Data.structures $structure).arguments | default dict }} - {{ else }} - {{ $args = index (index (index site.Data.structures.components $bookshop) (printf "%s.bookshop" $bookshop)) "blueprint" | default dict }} - {{ $args = merge $args (dict "_bookshop_name" nil "_ordinal" nil "id" nil) }} - {{/* Load augmented sidecar (e.g. preview.yml) if present */}} - {{ $sidecarData := index (index site.Data.structures.components $bookshop) $bookshop | default dict }} - {{ range $key, $sidecarArgDef := ($sidecarData.arguments | default dict) }} - {{ if reflect.IsMap $sidecarArgDef }} - {{/* Convert kebab sidecar key to snake_case to match blueprint key format */}} - {{ $snakeKey := replaceRE "-" "_" $key }} - {{ $existing := index $args $snakeKey | default (index $args $key) | default dict }} - {{ if not (reflect.IsMap $existing) }}{{ $existing = dict }}{{ end }} - {{/* Prefer snake key if it exists in blueprint, otherwise use as-is */}} - {{ $targetKey := cond (isset $args $snakeKey) $snakeKey $key }} - {{ $args = merge $args (dict $targetKey (merge $existing $sidecarArgDef)) }} - {{ end }} - {{ end }} - {{ end }} +{{ $compiled := partial "utilities/ArgsSchema.html" (dict + "structure" (.structure | default "") + "bookshop" (.bookshop | default "") + "child" (.child | default "") +) }} - {{/* Merge any child arguments */}} - {{ if $child }} - {{ $extra_def := (index site.Data.structures $child).arguments }} - {{ if not $extra_def }} - {{- $errmsg = $errmsg | append (printf "partial [utilities/InitTypes.html] - Missing definitions: %s" $child) -}} - {{ $error = true }} - {{ else }} - {{ range $key, $val := $extra_def }} - {{ if and $val $val.parent }} - {{ $newval := dict }} - {{ range $k, $v := $val }} - {{ if ne $k "default" }}{{ $newval = merge $newval (dict $k $v) }}{{ end }} - {{ end}} - {{ $args = merge $args (dict $key $newval) }} - {{ end }} - {{ end }} - {{ end }} - {{ end }} - - {{/* Initialize the arguments and their type definitions recursively */}} - {{ range $key, $v := $args }} - {{ $type := partial "inline/type-definition.html" (dict "key" $key "val" $v "args" $args "arguments" $arguments "types" $types "structure" $structure) }} - {{ $errmsg = $errmsg | append $type.errmsg }} - {{ if and $type $type.definition $type.definition.type }} - {{ $definitions = merge $definitions (dict $key $type.definition) }} - {{ with $type.udt }} - {{ $udt = merge $udt . }} - {{ end }} - {{ else }} - {{- $errmsg = $errmsg | append (printf "partial [utilities/InitTypes.html] - Missing type for '%s' in '%s'" $key (or $structure $bookshop) ) -}} - {{ $error = true }} +{{ $types := dict }} +{{ $udt := dict }} +{{ range $key, $node := $compiled.schema }} + {{ $types = merge $types (dict $key (partial "inline/legacy-def.html" $node)) }} + {{ if $node.children }} + {{ $reflect := "map[string]interface {}" }} + {{ if eq $node.kind "list" }}{{ $reflect = "[]interface {}" }}{{ end }} + {{ $members := dict "_reflect" $reflect }} + {{ range $member, $childNode := $node.children }} + {{ $members = merge $members (dict $member (partial "inline/legacy-def.html" $childNode)) }} {{ end }} + {{ $udt = merge $udt (dict $node.udtType $members) }} {{ end }} {{ end }} -{{ $params = merge $params (dict "types" $definitions "udt" $udt "err" $error "errmsg" $errmsg "warnmsg" $warnmsg) }} -{{ return $params }} \ No newline at end of file +{{ return (dict "types" $types "udt" $udt "err" $compiled.err "errmsg" $compiled.errmsg "warnmsg" slice) }} diff --git a/tests/golden/inittypes.json b/tests/golden/inittypes.json index f117016..7e97992 100644 --- a/tests/golden/inittypes.json +++ b/tests/golden/inittypes.json @@ -17,10 +17,8 @@ "type": "int" }, "heading": { - "align": "start", "comment": "Heading of the content block, including a preheading and content element.", "optional": false, - "title": null, "type": "heading" }, "id": { @@ -41,6 +39,7 @@ "type": "select" }, "show_more": { + "default": false, "optional": true, "type": "bool" }, @@ -86,12 +85,6 @@ }, "content": { "comment": "Section content displayed below the title.", - "editors": [ - { - "name": "cc", - "type": "textarea" - } - ], "optional": true, "type": [ "string", @@ -153,17 +146,6 @@ "ratio": { "comment": "Ratio of the media asset. When the asset is an image, it is resized and cropped (not applicable to vector graphics). For video assets, the padding of the embedded frame is adjusted. When set to auto, the original aspect ratio is used.", "optional": true, - "options": { - "values": [ - "1x1", - "3x1", - "3x2", - "4x3", - "16x9", - "21x9", - "auto" - ] - }, "parent": "merge", "type": "string" }, @@ -229,12 +211,6 @@ }, "content": { "comment": "Section content displayed below the title.", - "editors": [ - { - "name": "cc", - "type": "textarea" - } - ], "optional": true, "type": [ "string", @@ -306,12 +282,6 @@ }, "image": { "comment": "Image to include in the content block or section heading.", - "editors": [ - { - "name": "cc", - "type": "image" - } - ], "optional": true, "type": "string" }, From 848429b3633c09a8cf55cb4aa3d5019d03fed43b Mon Sep 17 00:00:00 2001 From: Mark Dumay <61946753+markdumay@users.noreply.github.com> Date: Sun, 12 Jul 2026 10:56:54 +0200 Subject: [PATCH 18/21] perf: cache compiled argument schemas per structure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switched ArgsSchema.html invocations to partialCached in Args.html, InitArgs.html, and InitTypes.html. Hinode exampleSite --templateMetrics: ArgsSchema.html cumulative: 2m5.18s → 0.88s (142x faster) Combined (3 partials): 6m25.82s → 1m9.12s (5.58x faster) Test result: golden check passed (12 groups) — zero behavior drift. Co-Authored-By: Claude Fable 5 --- layouts/_partials/utilities/Args.html | 2 +- layouts/_partials/utilities/InitArgs.html | 2 +- layouts/_partials/utilities/InitTypes.html | 14 +++++++++----- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/layouts/_partials/utilities/Args.html b/layouts/_partials/utilities/Args.html index a0e4987..1dbfb73 100644 --- a/layouts/_partials/utilities/Args.html +++ b/layouts/_partials/utilities/Args.html @@ -296,7 +296,7 @@ {{ $out := dict }} {{ $name := or $structure $bookshop }} -{{ $compiled := partial "utilities/ArgsSchema.html" (dict "structure" $structure "bookshop" $bookshop "child" $child) }} +{{ $compiled := partialCached "utilities/ArgsSchema.html" (dict "structure" $structure "bookshop" $bookshop "child" $child) (or $structure "") (or $bookshop "") (or $child "") }} {{ if $compiled.err }} {{ range $compiled.errmsg }}{{ $errmsg = $errmsg | append . }}{{ end }} {{ else }} diff --git a/layouts/_partials/utilities/InitArgs.html b/layouts/_partials/utilities/InitArgs.html index a1ff201..46ac087 100644 --- a/layouts/_partials/utilities/InitArgs.html +++ b/layouts/_partials/utilities/InitArgs.html @@ -46,7 +46,7 @@ {{ $warnmsg = $res.warnmsg }} {{ if not $error }} - {{ $compiled := partial "utilities/ArgsSchema.html" (dict "structure" $structure "bookshop" $bookshop "child" $child) }} + {{ $compiled := partialCached "utilities/ArgsSchema.html" (dict "structure" $structure "bookshop" $bookshop "child" $child) (or $structure "") (or $bookshop "") (or $child "") }} {{ range $key, $node := $compiled.schema }} {{ $val := index $res.args $node.camelKey }} {{ if ne $val nil }} diff --git a/layouts/_partials/utilities/InitTypes.html b/layouts/_partials/utilities/InitTypes.html index 0828f0e..587f665 100644 --- a/layouts/_partials/utilities/InitTypes.html +++ b/layouts/_partials/utilities/InitTypes.html @@ -25,11 +25,15 @@ {{ return $def }} {{ end }} -{{ $compiled := partial "utilities/ArgsSchema.html" (dict - "structure" (.structure | default "") - "bookshop" (.bookshop | default "") - "child" (.child | default "") -) }} +{{ $structure := .structure | default "" }} +{{ $bookshop := .bookshop | default "" }} +{{ $child := .child | default "" }} + +{{ $compiled := partialCached "utilities/ArgsSchema.html" (dict + "structure" $structure + "bookshop" $bookshop + "child" $child +) $structure $bookshop $child }} {{ $types := dict }} {{ $udt := dict }} From cfc4df973c7cb7e7b70020ccd2de3c1d53f0f13b Mon Sep 17 00:00:00 2001 From: Mark Dumay <61946753+markdumay@users.noreply.github.com> Date: Sun, 12 Jul 2026 11:07:24 +0200 Subject: [PATCH 19/21] docs: document the Args validation API and strictness rollout Add a README "Argument validation" section covering the Args.html signature/envelope, the ArgsSchema.html node contract, the InitArgs/ InitTypes shim migration note, the null-vs-explicit tolerance rule, and the warnings-first classes slated for promotion to errors after one release cycle. Mark the design spec Implemented (phases 0-4) and record the deviation log discovered during execution: the single-return-per- define restriction, element-wise message appends, reflect-type passthrough, the untestable cycle fixture, the excess-positional adjudication, the four core fixes from the Hinode smoke test, and the two legacy-behavior corrections (dict alias, false/0 defaults). Co-Authored-By: Claude Fable 5 --- README.md | 107 ++++++++++++++++++ ...-07-12-args-type-system-redesign-design.md | 85 +++++++++++++- 2 files changed, 191 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 4bf6188..da15a04 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,113 @@ This module supports the following parameters (see the section `params.modules` | utils.filter | `[^0-9A-Za-zŽžÀ-ÿ ;.,\/'’"]` | Defines the regular expression for characters to remove from page descriptions. These page descriptions are used to define card content and metadata for search indexes. Adjust the filter to define which characters to support. You may need to adjust these settings to support specific diacritical letters. | | utils.raw | false | Flag to indicate page descriptions should be returned as-is. In this setting, the filter is ignored. | +## Argument validation + +This module provides the argument and type validation system used by shortcodes, partials, and +Bookshop components across the Hinode ecosystem. As of v5 the system is split into a cached +schema compiler (`ArgsSchema.html`), a strict recursive validator (`Args.html`), and two +compatibility shims (`InitArgs.html`, `InitTypes.html`) that preserve the legacy contract for +existing call sites. + +### `Args.html` — clean entry point + +```text +partial "utilities/Args.html" (dict + "structure" S | "bookshop" B # exactly one required + "child" C # optional + "args" .Params # map (named) or slice (positional) + "named" true|false # default true + "group" "shortcode"|"partial"|… # optional required-arg filter + "strict" true|false # default true +) +``` + +Returns a separated envelope — user values never share a namespace with bookkeeping keys: + +```text +{ args: { → value }, # camelCase-only canonical keys + err: bool, errmsg: []string, warnmsg: []string, defaulted: []string } +``` + +`defaulted` lists the dotted path (e.g. `heading.align`) of every value that was filled from a +default, at any nesting depth. + +The legacy `bookshop-` prefix sugar (passing `structure: "bookshop-hero"` to redirect to the +`hero` Bookshop component) is **not** part of the clean API — it is preserved only inside the +`InitArgs.html` shim for backward compatibility. Callers using `Args.html` directly must pass +`bookshop` explicitly. + +### `ArgsSchema.html` — cached schema compiler + +`Args.html` resolves its schema through `ArgsSchema.html`, invoked (and cached) per +`(structure, bookshop, child)` triple: + +```text +partial "utilities/ArgsSchema.html" (dict "structure" S "bookshop" B "child" C) + → dict: + "schema" map: declared argument name → node + "positions" map: position as string → argument name + "err" bool + "errmsg" []string, each prefixed "schema:" for compile-time problems +node: + "name" string declared argument name + "camelKey" string canonical access key (kebab/snake → camelCase, leading _ kept) + "types" []string declared type names + "accepts" []string value-kind tokens: string|bool|int|float|map|slice|maplist + "kind" string scalar|dict|list + "children" map member name → node (single user-defined type only) + "udtType" string the user-defined type name (only when children present) + "default", "config", "options", "position", "group" ([]string), + "deprecated", "alternative", "release", "parent", "comment" — present only when defined + "optional" bool always present on every node + "reflects" []string optional; raw package-qualified Go reflect-type names (e.g. + "template.HTML") declared in the data files, matched verbatim against + printf "%T" of the value at validation time +``` + +Callers MUST invoke it through +`partialCached "utilities/ArgsSchema.html" (dict …) (or $structure "") (or $bookshop "") (or $child "")` +to benefit from caching; a plain `partial` call still works but recompiles the schema on every +call. + +### Migration note + +`InitArgs.html` and `InitTypes.html` remain supported as compatibility shims within v5. They +preserve the legacy flat-map return shape (argument values under their declared names plus +camelCase duplicates, merged with `err`/`errmsg`/`warnmsg`/`default`) and run `Args.html` in +non-strict mode. New code should call `Args.html` (and `ArgsSchema.html`, where the raw compiled +schema is needed) directly. There is no plan to remove the shims within v5; migrating existing +call sites across Hinode, mod-blocks, and other modules to the clean API is a separate, deferred +effort. + +### Null-tolerance rule + +At every nesting level, `null`/absent is treated as "not provided": it is eligible for +defaulting (an `isset`-based `config` site-parameter lookup first — honoring an explicit `false` +or `0` site value — then the static `default`) and exempt from type, option, and range errors. +This is distinct from a value the caller *explicitly* provided as `false`, `0`, or `""`, which +**is** validated against the declared type. This is what lets a CloudCannon-authored payload +(every blueprint key present, typically `null`, plus `_bookshop_name`/`_ordinal`) build clean +while a genuinely wrong explicit value is still caught. + +### Warnings-first strictness rollout + +The following newly detectable problem classes surface as **warnings** (`warnmsg`, `err: false`) +through the `InitArgs.html`/`InitTypes.html` shims, rather than errors, for one full v5 release +cycle: + +- Falsy-value type/select mismatches at the top level (e.g. `0` failing an `int` check, `false` + failing a `select` check). +- Nested member type/select/range findings (anything below the top level). +- Unknown nested attributes. +- Excess positional arguments. + +Calling `Args.html` directly with `strict: true` (the default) already treats every one of these +as an error today. After one release cycle of the shim surfacing them as warnings, they will be +promoted to errors in `InitArgs.html`/`InitTypes.html` too. Watch your own build's `warnmsg` +output (or switch early to `Args.html`) to find call sites that need fixing before that +promotion release. + ## Contributing This module uses [semantic-release][semantic-release] to automate the release of new versions. The package uses `husky` and `commitlint` to ensure commit messages adhere to the [Conventional Commits][conventionalcommits] specification. You can run `npx git-cz` from the terminal to help prepare the commit message. diff --git a/docs/superpowers/specs/2026-07-12-args-type-system-redesign-design.md b/docs/superpowers/specs/2026-07-12-args-type-system-redesign-design.md index 769067f..701fd36 100644 --- a/docs/superpowers/specs/2026-07-12-args-type-system-redesign-design.md +++ b/docs/superpowers/specs/2026-07-12-args-type-system-redesign-design.md @@ -1,7 +1,7 @@ # Design: Argument and Type System Redesign (core + shim) - **Date:** 2026-07-12 -- **Status:** Draft — pending user review +- **Status:** Implemented (phases 0–4) - **Scope:** mod-utils v5 (`InitArgs.html`, `InitTypes.html`, and supporting data files) - **Consumers affected:** ~160 call sites across Hinode, mod-blocks, and 13+ other modules; docs generation (`args.md` shortcode in Hinode); Bookshop blueprint and @@ -360,3 +360,86 @@ consistent with how mod-utils has rolled out stricter validation historically. (all-null blueprints, snake_case, implicit args) and the `child` structure path; all new files respect the expose-glob placement rule (§1.3); live editing verified manually on a CloudCannon-connected site before the phase-3 release. + +## 8. Deviation log (phases 0–4, as executed) + +Recorded during implementation (Tasks 6–10); each item is a deviation from this spec's literal +wording, not a change to its intent. See `.superpowers/sdd/progress.md` and +`.superpowers/sdd/task-{6,7,8,9}-report.md` for full detail. + +1. **Hugo single-return-per-define restriction.** The pinned toolchain (hugo-extended 0.164.0) + rejects a second `{{ return }}` in the same `define` block — including on an unreached branch + — with `wrong number of args for return: want 0 got 1`. Every partial in this design that the + spec sketches with multiple early-exit returns (`compile-node.html`, `validate-node.html`, + `site-param.html`'s bare-`nil` assignment) was restructured to a single unconditional trailing + `return`, using guard variables and if/else branch assignment instead of early exits. No + semantics changed — verified by byte-identical goldens for every case not targeted by an + intentional fix. + +2. **Element-wise message appends.** Hugo's `append` template function only flattens a single + appended slice into an accumulator when the accumulator's concrete Go type matches the + appended slice's type. A whole-slice `$acc = $acc | append $child.errmsg` silently nests + instead of flattening once a zero-error member (contributing an untyped empty slice) is + interleaved, in key order, with error-producing members (contributing a concretely-typed + `[]string`) — breaking the documented "flat `[]string`" contract for `errmsg`/`warnmsg`. + Every accumulation site in `ArgsSchema.html`, `Args.html`, and their inline `define`s uses an + explicit `range`/`append` loop instead of a whole-slice `append`. + +3. **Reflect-type passthrough via a `reflects` node field, instead of erroring on Go type + names.** Real data declares union types that include bare Go reflect-type strings (the global + `title`/`content`/`description` definitions: `hstring.RenderedString`, `hstring.HTML`, + `template.HTML`; `log.details`: `[]string`, `[]interface {}`; `icon.type`: `uint64`). Rather + than treating every non-primitive-alias, non-UDT type name as a schema compile error, + `ArgsSchema.html` collects names containing `.`, `[`, or `*` into an optional node field + `reflects` ([]string, declared order) and matches them verbatim against `printf "%T"` at + validation time — legacy parity. Dot/bracket/asterisk-less barewords still error as genuine + unknown-type typos. Without this, most real Hinode structures (anything reusing the global + `title`, or `log.yml` itself — used by every warning/error call in the ecosystem) would fail + to compile. + +4. **Cycle fixture impossible from test-only data.** The golden harness could not construct a + genuine circular user-defined-type reference (a UDT referencing itself or an ancestor) purely + from test-only fixture data without a production structure change out of scope for this + effort. The compiler's cycle-detection stack guard is therefore verified by code inspection + and an isolated, uncommitted repro rather than an end-to-end golden case, and remains + defensive rather than regression-tested. + +5. **Excess-positional findings are warnings-first in shim mode (spec criterion 5 governed).** + The plan originally pre-authorized excess positional arguments as an error-classified golden + diff. During the Task 8 Hinode smoke test this broke `mod-fontawesome`'s `fas`/`fab`/`icon` + shortcodes (they intentionally forward extra positions they already handle themselves). Per + acceptance criterion 5 ("Hinode … build with zero new *errors* through the shim path"), this + was overruled: excess-positional findings are now classified as newly-detectable + (warnings-first) like the other new problem classes, promoted to errors only on the strict + (`Args.html`, `strict: true`) path. + +6. **Four core fixes surfaced by the Hinode smoke test (Task 8), beyond that task's literal + scope, because they blocked real content from building:** + - The numeric range/min-max check's falsy exemption was removed — legacy never exempted `0` + from range checks (only the type check has a legacy-documented falsy skip, defect 3); the + exemption had been copy-pasted onto the range check too. + - Reflect-type-name detection was broadened from dot-only to dot/bracket/asterisk-containing + names, so composite/pointer Go types (`[]string`, `map[string]interface {}`, + `*resources.resourceAdapter`) pass through instead of erroring. + - The integer alias set was extended to `uint`/`uint8..64` (previously only `int`/`int64` + were recognized), matching `inline/value-kind.html`'s existing "int" kind grouping. + - Same-named global/local arguments that redeclare `type` to something unrelated now merge + from a clean slate (global `default`/`config`/`options` stripped before merging the local + override) instead of leaking the global's value-resolution fields across an unrelated type + — this was a hard `errorf`-level build crash on every Hinode page via the navbar's + `button-size`/`size` collision. + +7. **The legacy `dict` type alias never matched real data — its replacement is a fix, not a + preserved quirk.** Early plan language described the legacy `dict` alias + (`[]map[string]interface {}`) as a "quirk kept on purpose." End-to-end characterization + (Task 2) showed it matched neither a real Go map nor a slice of maps passed by any real + caller — both forms produced a hard build crash via `findRE`. The redesigned `dict` kind + (accepting `map` or `maplist`) is a deliberate, intentional fix, not legacy-quirk + preservation. + +8. **False/0 defaults are now applied — legacy never applied them.** Legacy's + `or $def.config $def.default` fallback treats `false`/`0` as falsy and silently skips them in + favor of no default at all (defect 4's sibling on the static-default path, not just the + `config` path). The redesign resolves defaults via `isset`, so a static `default: false` or + `default: 0` now actually applies — visible as a golden flip wherever a component declares a + falsy default (e.g. Bookshop's `show_more`). From b20a676e4f8594b17fa1aa1ef7a3d91f24ea1f68 Mon Sep 17 00:00:00 2001 From: Mark Dumay <61946753+markdumay@users.noreply.github.com> Date: Sun, 12 Jul 2026 11:34:02 +0200 Subject: [PATCH 20/21] feat: release the argument system redesign as a major version Bumps the Go module path to /v6 and documents the v5-to-v6 migration (import paths, CloudCannon expose globs, behavior changes). The warning-to-error promotion is now planned as v7. BREAKING CHANGE: although the InitArgs/InitTypes API is drop-in compatible, validation behavior changes: nested and falsy defaults now apply (rendered output can change), null members are dropped from nested maps, all validation problems are reported per call, explicit falsy site parameters override static defaults, and newly detectable problem classes surface as warnings. Consumers must update the import path to github.com/gethinode/mod-utils/v6 and, when using setup-cloudcannon-cms, refresh expose globs that reference the vendored v5 path. Co-Authored-By: Claude Fable 5 --- README.md | 40 +++++++++++++++---- ...-07-12-args-type-system-redesign-design.md | 22 ++++++---- go.mod | 2 +- 3 files changed, 49 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index da15a04..0c56fb0 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ This module supports the following parameters (see the section `params.modules` ## Argument validation This module provides the argument and type validation system used by shortcodes, partials, and -Bookshop components across the Hinode ecosystem. As of v5 the system is split into a cached +Bookshop components across the Hinode ecosystem. As of v6 the system is split into a cached schema compiler (`ArgsSchema.html`), a strict recursive validator (`Args.html`), and two compatibility shims (`InitArgs.html`, `InitTypes.html`) that preserve the legacy contract for existing call sites. @@ -135,8 +135,8 @@ while a genuinely wrong explicit value is still caught. ### Warnings-first strictness rollout The following newly detectable problem classes surface as **warnings** (`warnmsg`, `err: false`) -through the `InitArgs.html`/`InitTypes.html` shims, rather than errors, for one full v5 release -cycle: +through the `InitArgs.html`/`InitTypes.html` shims, rather than errors, for the full v6 release +line: - Falsy-value type/select mismatches at the top level (e.g. `0` failing an `int` check, `false` failing a `select` check). @@ -145,10 +145,36 @@ cycle: - Excess positional arguments. Calling `Args.html` directly with `strict: true` (the default) already treats every one of these -as an error today. After one release cycle of the shim surfacing them as warnings, they will be -promoted to errors in `InitArgs.html`/`InitTypes.html` too. Watch your own build's `warnmsg` -output (or switch early to `Args.html`) to find call sites that need fixing before that -promotion release. +as an error today. The promotion of these warnings to errors in `InitArgs.html`/`InitTypes.html` +is planned as the next major release (v7). Watch your own build's `warnmsg` output (or switch +early to `Args.html`) to find call sites that need fixing before that promotion release. + +### Migrating from v5 to v6 + +v6 is a major release: the API of `InitArgs.html`/`InitTypes.html` is drop-in compatible, but +behavior changes in ways that can affect rendered output and build logs. + +1. **Import path.** Update `go.mod` and the `[module.imports]` path in your Hugo configuration + from `github.com/gethinode/mod-utils/v5` to `github.com/gethinode/mod-utils/v6`, then re-vendor + (`npm run mod:vendor` or `hugo mod vendor`). +2. **CloudCannon expose configuration.** If your site uses `setup-cloudcannon-cms`, update any + expose globs that hardcode the vendored path (e.g. + `_vendor/github.com/gethinode/mod-utils/v5/...` → `.../v6/...`) and regenerate + `bookshop.config.cjs`; otherwise the live editor silently loses access to the validation + partials and structure data. +3. **Behavior changes to expect.** + - Nested defaults now apply correctly (e.g. an omitted `heading` receives its members' real + defaults such as `align: start`, `width: 8`) — previously they resolved to null. Rendered + output can change without any content edit. + - Defaults with falsy values (`false`, `0`) are now applied; previously they were silently + ignored. + - Explicit `null` members inside nested arguments are dropped from the returned map instead of + passed through. + - All validation problems are reported per call instead of only the first one. + - Site parameters explicitly set to `false`/`0` now take precedence over static defaults for + `config`-driven arguments. + - New warnings (see the rollout section above) may appear in your build logs; they are + non-blocking in v6 and indicate call sites to clean up before v7. ## Contributing diff --git a/docs/superpowers/specs/2026-07-12-args-type-system-redesign-design.md b/docs/superpowers/specs/2026-07-12-args-type-system-redesign-design.md index 701fd36..2f633e1 100644 --- a/docs/superpowers/specs/2026-07-12-args-type-system-redesign-design.md +++ b/docs/superpowers/specs/2026-07-12-args-type-system-redesign-design.md @@ -114,7 +114,7 @@ Traced through `setup-cloudcannon-cms`, mod-blocks, and mod-bookshop(-hugo): | D4 | Performance | **In scope** — cache compiled schemas with `partialCached`. | | D5 | Canonical keys in clean API | **camelCase only** in the `Args.html` envelope; the shim keeps today's duplicated spellings. | | D6 | Unknown nested keys | **Error in strict mode** (consistent with top-level), warnings-first during transition. | -| D7 | Strictness rollout | Newly detectable error classes ship as **warnings for one release cycle** via the shim, then are promoted to errors. | +| D7 | Strictness rollout | Newly detectable error classes ship as **warnings in v6** via the shim, then are promoted to errors in **v7** (revised: promotion is a major, see §5). | ### 2.1 Non-goals @@ -305,12 +305,20 @@ inputs testable with golden files. | 4 | Caching enabled and measured (`hugo --templateMetrics` before/after on Hinode exampleSite) | No behavior drift in goldens; measurable build-time improvement | | 5 (follow-up, separate effort) | Migrate Hinode v2 + first-party modules to `Args.html`; docs `args.md` consumes `ArgsSchema`; promote warnings to errors after one release cycle | Per-module PRs | -Phases 0–4 ship as mod-utils v5 minor releases (with clear release notes for the -new warnings). The phase-5 warning→error promotion is behaviorally breaking in the -strict semver sense, but a Go-module major bump would force a `/v6` import path — -the ecosystem-fork problem that ruled out scenario C. It therefore ships as a -well-communicated v5 minor after at least one full release cycle of warnings, -consistent with how mod-utils has rolled out stricter validation historically. +**Release decision (revised post-implementation, 2026-07-12):** phases 0–4 ship +together as **mod-utils v6** — a major release with a `/v6` import path. Although +the API is drop-in compatible, implementation surfaced behavior changes that exceed +what a minor should carry: nested and false/zero defaults now actually apply (which +can change rendered output on untouched sites), null members are dropped from +nested maps, and ~55 distinct new warning patterns surface on Hinode's exampleSite +alone. A major makes adoption opt-in per module and per site. The original +ecosystem-fork concern proved weaker than assumed: the ecosystem has absorbed four +mod-utils majors with existing update automation, and mixed v5/v6 module graphs +degrade gracefully (each version's partial set is self-contained). Consequences +accepted: v5 freezes with its known defects (fixes do not propagate automatically), +and CloudCannon expose configurations that hardcode `_vendor/.../mod-utils/v5/` +globs must be updated. Hinode absorbs mod-utils v6 in its own next major (naturally +Hinode v2). The phase-5 warning→error promotion becomes a clean v7. ## 6. Risks and mitigations diff --git a/go.mod b/go.mod index aa069d7..be96f04 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,3 @@ -module github.com/gethinode/mod-utils/v5 +module github.com/gethinode/mod-utils/v6 go 1.19 From 894673665a2ca670a614740f7d855037ed1916e0 Mon Sep 17 00:00:00 2001 From: Mark Dumay <61946753+markdumay@users.noreply.github.com> Date: Sun, 12 Jul 2026 11:39:42 +0200 Subject: [PATCH 21/21] fix(test): make golden comparison line-ending agnostic Windows CI checks out the golden files with CRLF conversion while Hugo emits LF, so byte comparison failed on windows-latest only. Pin tests/golden/*.json to LF via .gitattributes and normalize line endings in golden.mjs as a defensive second layer. Co-Authored-By: Claude Fable 5 --- .gitattributes | 2 ++ tests/golden.mjs | 8 ++++++-- 2 files changed, 8 insertions(+), 2 deletions(-) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..e622813 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Golden test files are byte-compared against Hugo output; never convert their line endings. +tests/golden/*.json text eol=lf diff --git a/tests/golden.mjs b/tests/golden.mjs index d0fbd42..72fe210 100644 --- a/tests/golden.mjs +++ b/tests/golden.mjs @@ -9,6 +9,10 @@ const generatedRoot = path.join('exampleSite', 'public', 'tests'); const goldenRoot = path.join('tests', 'golden'); const update = process.argv.includes('--update'); +// Normalize line endings so a CRLF checkout (e.g. git autocrlf on Windows) compares equal to +// Hugo's LF output; .gitattributes pins the goldens to LF, this is the defensive second layer. +const normalize = (s) => s.replaceAll('\r\n', '\n'); + const groups = readdirSync(generatedRoot, {withFileTypes: true}) .filter((entry) => entry.isDirectory() && existsSync(path.join(generatedRoot, entry.name, 'index.json'))) @@ -22,7 +26,7 @@ if (groups.length === 0) { let failed = false; const seen = new Set(); for (const group of groups) { - const generated = readFileSync(path.join(generatedRoot, group, 'index.json'), 'utf8'); + const generated = normalize(readFileSync(path.join(generatedRoot, group, 'index.json'), 'utf8')); const goldenFile = path.join(goldenRoot, `${group}.json`); seen.add(`${group}.json`); if (update) { @@ -38,7 +42,7 @@ for (const group of groups) { continue; } - const golden = readFileSync(goldenFile, 'utf8'); + const golden = normalize(readFileSync(goldenFile, 'utf8')); if (golden !== generated) { failed = true; console.error(`DIFF in group '${group}' (golden vs generated):`);