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/README.md b/README.md index 4bf6188..0c56fb0 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,139 @@ 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 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. + +### `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 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). +- 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. 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 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/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). 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..2f633e1 --- /dev/null +++ b/docs/superpowers/specs/2026-07-12-args-type-system-redesign-design.md @@ -0,0 +1,453 @@ +# Design: Argument and Type System Redesign (core + shim) + +- **Date:** 2026-07-12 +- **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 + 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. 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) + +| # | 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 in v6** via the shim, then are promoted to errors in **v7** (revised: promotion is a major, see §5). | + +### 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`. +- **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. +- **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 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 | + +**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 + +- **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; 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. +- **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. +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. + +## 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`). 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/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/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/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/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/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/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/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/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/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/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 new file mode 100644 index 0000000..9071608 --- /dev/null +++ b/exampleSite/data/structures/_types.yml @@ -0,0 +1,118 @@ +# 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 --- + test-node: + title: + member-without-global-def: 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/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-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-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/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-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/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-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/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/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/bookshop.yml b/exampleSite/data/tests/bookshop.yml new file mode 100644 index 0000000..610537f --- /dev/null +++ b/exampleSite/data/tests/bookshop.yml @@ -0,0 +1,53 @@ +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. + # 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: + _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/exampleSite/data/tests/casting.yml b/exampleSite/data/tests/casting.yml new file mode 100644 index 0000000..c80137e --- /dev/null +++ b/exampleSite/data/tests/casting.yml @@ -0,0 +1,53 @@ +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} + # 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"} + # 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: ""} + # 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} + # 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: + data: + - anything: goes + - name: generic-slice + structure: test-cast + args: + items-list: [1, "two", 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/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/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/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/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/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/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/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/hugo.toml b/exampleSite/hugo.toml index ccbc6c8..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]] @@ -11,4 +16,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..dd5bee1 --- /dev/null +++ b/exampleSite/layouts/tests/single.json @@ -0,0 +1,39 @@ +{{- /* + 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 -}} + {{- $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 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 "") + "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) -}} + {{- $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 -}} +{{- jsonify (dict "indent" " ") $results -}} 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 diff --git a/layouts/_partials/utilities/Args.html b/layouts/_partials/utilities/Args.html new file mode 100644 index 0000000..1dbfb73 --- /dev/null +++ b/layouts/_partials/utilities/Args.html @@ -0,0 +1,380 @@ + + +{{/* + 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 }} + {{/* 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 }} + {{ 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 := 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 }} + {{ $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 }} + {{/* 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 }} + + {{/* 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/layouts/_partials/utilities/ArgsSchema.html b/layouts/_partials/utilities/ArgsSchema.html new file mode 100644 index 0000000..c134062 --- /dev/null +++ b/layouts/_partials/utilities/ArgsSchema.html @@ -0,0 +1,255 @@ + + +{{/* + 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. 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" }} + {{ $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 }} + {{ 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 }} + + {{ 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 }} + {{ $reflects := slice }} + + {{ 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" "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" }} + {{ 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 }} + {{/* 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) }} + {{ end }} + {{ 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) + ) }} + {{/* 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 }} + {{ 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 $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 }} + {{ 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) + ) }} + {{/* 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" }} + {{ $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/layouts/_partials/utilities/InitArgs.html b/layouts/_partials/utilities/InitArgs.html index 594ea61..46ac087 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 := 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 }} + {{ $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/layouts/_partials/utilities/InitTypes.html b/layouts/_partials/utilities/InitTypes.html index 198dcc6..587f665 100644 --- a/layouts/_partials/utilities/InitTypes.html +++ b/layouts/_partials/utilities/InitTypes.html @@ -1,156 +1,53 @@ - -{{/* 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) }} + {{ return $def }} {{ 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 }} +{{ $structure := .structure | default "" }} +{{ $bookshop := .bookshop | default "" }} +{{ $child := .child | default "" }} -{{ if and (not $structure) (not $bookshop) }} - {{- $errmsg = $errmsg | append (printf "partial [utilities/InitTypes.html] - Missing value for param 'structure' or 'bookshop'") -}} - {{ $error = true }} -{{ end }} +{{ $compiled := partialCached "utilities/ArgsSchema.html" (dict + "structure" $structure + "bookshop" $bookshop + "child" $child +) $structure $bookshop $child }} -{{/* 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 }} - - {{/* 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/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..72fe210 --- /dev/null +++ b/tests/golden.mjs @@ -0,0 +1,69 @@ +#!/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'); + +// 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'))) + .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 = normalize(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 = normalize(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/bookshop.json b/tests/golden/bookshop.json new file mode 100644 index 0000000..180a074 --- /dev/null +++ b/tests/golden/bookshop.json @@ -0,0 +1,266 @@ +{ + "bookshop-prefix-via-structure": { + "args": { + "args": {}, + "defaulted": [], + "err": true, + "errmsg": [ + "[bookshop-test-hero] unsupported argument 'heading'" + ], + "warnmsg": [] + }, + "initargs": { + "default": [ + "link_type", + "show_more", + "width" + ], + "err": false, + "errmsg": [], + "heading": { + "align": "start", + "arrangement": "above", + "size": 4, + "title": "Hello", + "width": 8 + }, + "linkType": "button", + "link_type": "button", + "showMore": false, + "show_more": false, + "warnmsg": [], + "width": 8 + } + }, + "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": { + "default": [], + "err": true, + "errmsg": [ + "[test-hero] argument 'width': value '0' out of range [1, 12]" + ], + "warnmsg": [] + } + }, + "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": { + "_bookshopName": "test-hero", + "_bookshop_name": "test-hero", + "_ordinal": 0, + "default": [ + "link_type", + "show_more", + "width" + ], + "err": false, + "errmsg": [], + "heading": { + "align": "start", + "arrangement": "above", + "size": 4, + "title": "Hello", + "width": 8 + }, + "linkType": "button", + "link_type": "button", + "showMore": false, + "show_more": false, + "warnmsg": [], + "width": 8 + } + }, + "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": [ + "show_more", + "width" + ], + "err": false, + "errmsg": [], + "heading": { + "align": "start", + "arrangement": "above", + "size": 4, + "title": "Hello", + "width": 8 + }, + "linkType": "link", + "link_type": "link", + "showMore": false, + "show_more": false, + "warnmsg": [ + "[test-hero] argument 'link-type': prefer snake_case 'link_type'" + ], + "width": 8 + } + }, + "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": { + "_bookshopName": "test-hero", + "_bookshop_name": "test-hero", + "default": [ + "width" + ], + "err": false, + "errmsg": [], + "heading": { + "align": "start", + "arrangement": "above", + "size": 4, + "title": "Hello", + "width": 8 + }, + "linkType": "link", + "link_type": "link", + "showMore": true, + "show_more": true, + "warnmsg": [], + "width": 8 + } + }, + "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, + "errmsg": [ + "[test-hero] unsupported argument 'bogus'" + ], + "warnmsg": [] + } + } +} \ No newline at end of file diff --git a/tests/golden/casting.json b/tests/golden/casting.json new file mode 100644 index 0000000..7a7497d --- /dev/null +++ b/tests/golden/casting.json @@ -0,0 +1,257 @@ +{ + "bool-arg-real-bool": { + "args": { + "args": { + "flag": true + }, + "defaulted": [], + "err": false, + "errmsg": [], + "warnmsg": [] + }, + "initargs": { + "default": [], + "err": false, + "errmsg": [], + "flag": true, + "warnmsg": [] + } + }, + "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, + "errmsg": [], + "ratio-value": "", + "ratioValue": "", + "warnmsg": [ + "[test-cast] argument 'ratio-value': expected type 'float', got 'string' with value ''" + ] + } + }, + "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": [], + "err": false, + "errmsg": [], + "warnmsg": [ + "[test-cast] argument 'count': expected type 'int', got 'bool' with value 'false'" + ] + } + }, + "generic-dict-as-map": { + "args": { + "args": { + "data": { + "anything": "goes" + } + }, + "defaulted": [], + "err": false, + "errmsg": [], + "warnmsg": [] + }, + "initargs": { + "data": { + "anything": "goes" + }, + "default": [], + "err": false, + "errmsg": [], + "warnmsg": [] + } + }, + "generic-dict-as-maplist": { + "args": { + "args": { + "data": [ + { + "anything": "goes" + } + ] + }, + "defaulted": [], + "err": false, + "errmsg": [], + "warnmsg": [] + }, + "initargs": { + "data": [ + { + "anything": "goes" + } + ], + "default": [], + "err": false, + "errmsg": [], + "warnmsg": [] + } + }, + "generic-slice": { + "args": { + "args": { + "itemsList": [ + 1, + "two", + true + ] + }, + "defaulted": [], + "err": false, + "errmsg": [], + "warnmsg": [] + }, + "initargs": { + "default": [], + "err": false, + "errmsg": [], + "items-list": [ + 1, + "two", + true + ], + "itemsList": [ + 1, + "two", + true + ], + "warnmsg": [] + } + }, + "int-to-string": { + "args": { + "args": { + "label": "7" + }, + "defaulted": [], + "err": false, + "errmsg": [], + "warnmsg": [] + }, + "initargs": { + "default": [], + "err": false, + "errmsg": [], + "label": "7", + "warnmsg": [] + } + }, + "real-float-value": { + "args": { + "args": { + "ratioValue": 1.5 + }, + "defaulted": [], + "err": false, + "errmsg": [], + "warnmsg": [] + }, + "initargs": { + "default": [], + "err": false, + "errmsg": [], + "ratio-value": 1.5, + "ratioValue": 1.5, + "warnmsg": [] + } + }, + "string-to-bool": { + "args": { + "args": { + "flag": true + }, + "defaulted": [], + "err": false, + "errmsg": [], + "warnmsg": [] + }, + "initargs": { + "default": [], + "err": false, + "errmsg": [], + "flag": true, + "warnmsg": [] + } + }, + "string-to-float": { + "args": { + "args": { + "ratioValue": 1.5 + }, + "defaulted": [], + "err": false, + "errmsg": [], + "warnmsg": [] + }, + "initargs": { + "default": [], + "err": false, + "errmsg": [], + "ratio-value": 1.5, + "ratioValue": 1.5, + "warnmsg": [] + } + }, + "string-to-int": { + "args": { + "args": { + "count": 42 + }, + "defaulted": [], + "err": false, + "errmsg": [], + "warnmsg": [] + }, + "initargs": { + "count": 42, + "default": [], + "err": false, + "errmsg": [], + "warnmsg": [] + } + }, + "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, + "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/child.json b/tests/golden/child.json new file mode 100644 index 0000000..5a393ee --- /dev/null +++ b/tests/golden/child.json @@ -0,0 +1,59 @@ +{ + "child-args-cascade": { + "args": { + "args": { + "hook": "custom-hook", + "title": "Stack" + }, + "defaulted": [], + "err": false, + "errmsg": [], + "warnmsg": [] + }, + "initargs": { + "default": [], + "err": false, + "errmsg": [], + "hook": "custom-hook", + "title": "Stack", + "warnmsg": [] + } + }, + "child-default-stripped": { + "args": { + "args": { + "title": "Stack" + }, + "defaulted": [], + "err": false, + "errmsg": [], + "warnmsg": [] + }, + "initargs": { + "default": [], + "err": false, + "errmsg": [], + "title": "Stack", + "warnmsg": [] + } + }, + "child-non-parent-arg-rejected": { + "args": { + "args": {}, + "defaulted": [], + "err": true, + "errmsg": [ + "[test-stack] unsupported argument 'ignored-non-parent'" + ], + "warnmsg": [] + }, + "initargs": { + "default": [], + "err": true, + "errmsg": [ + "[test-stack] unsupported argument 'ignored-non-parent'" + ], + "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..4c765f4 --- /dev/null +++ b/tests/golden/defaults.json @@ -0,0 +1,222 @@ +{ + "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", + "from-config-false", + "from-config-missing", + "from-config-true", + "static-label" + ], + "err": false, + "errmsg": [], + "from-config": "from-site-params", + "from-config-false": false, + "from-config-missing": "static-fallback", + "from-config-true": true, + "fromConfig": "from-site-params", + "fromConfigFalse": false, + "fromConfigMissing": "static-fallback", + "fromConfigTrue": true, + "static-label": "fallback", + "staticLabel": "fallback", + "warnmsg": [] + } + }, + "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", + "from-config-false", + "from-config-missing", + "from-config-true", + "static-label" + ], + "err": false, + "errmsg": [], + "from-config": "from-site-params", + "from-config-false": false, + "from-config-missing": "static-fallback", + "from-config-true": true, + "fromConfig": "from-site-params", + "fromConfigFalse": false, + "fromConfigMissing": "static-fallback", + "fromConfigTrue": true, + "static-label": "fallback", + "staticLabel": "fallback", + "warnmsg": [] + } + }, + "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", + "from-config-false", + "from-config-missing", + "from-config-true", + "static-label" + ], + "err": false, + "errmsg": [], + "from-config": "from-site-params", + "from-config-false": false, + "from-config-missing": "static-fallback", + "from-config-true": true, + "fromConfig": "from-site-params", + "fromConfigFalse": false, + "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": { + "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", + "from-config-false", + "from-config-missing", + "from-config-true", + "static-label" + ], + "err": false, + "errmsg": [], + "from-config": "from-site-params", + "from-config-false": false, + "from-config-missing": "static-fallback", + "from-config-true": true, + "fromConfig": "from-site-params", + "fromConfigFalse": false, + "fromConfigMissing": "static-fallback", + "fromConfigTrue": true, + "static-label": "fallback", + "staticLabel": "fallback", + "warnmsg": [] + } + }, + "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", + "from-config-false", + "from-config-missing", + "from-config-true" + ], + "err": false, + "errmsg": [], + "from-config": "from-site-params", + "from-config-false": false, + "from-config-missing": "static-fallback", + "from-config-true": true, + "fromConfig": "from-site-params", + "fromConfigFalse": false, + "fromConfigMissing": "static-fallback", + "fromConfigTrue": true, + "static-label": "custom", + "staticLabel": "custom", + "warnmsg": [] + } + } +} \ No newline at end of file diff --git a/tests/golden/envelope.json b/tests/golden/envelope.json new file mode 100644 index 0000000..5619eb6 --- /dev/null +++ b/tests/golden/envelope.json @@ -0,0 +1,37 @@ +{ + "default-arg-collision": { + "args": { + "args": { + "default": "user-value" + }, + "defaulted": [], + "err": false, + "errmsg": [], + "warnmsg": [] + }, + "initargs": { + "default": [], + "err": false, + "errmsg": [], + "warnmsg": [] + } + }, + "plain-arg": { + "args": { + "args": { + "plain": "hello" + }, + "defaulted": [], + "err": false, + "errmsg": [], + "warnmsg": [] + }, + "initargs": { + "default": [], + "err": false, + "errmsg": [], + "plain": "hello", + "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..adf7fac --- /dev/null +++ b/tests/golden/frontmatter.json @@ -0,0 +1,58 @@ +{ + "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, + "errmsg": [], + "heading": { + "align": "center", + "arrangement": "above", + "size": 4, + "title": "Hello", + "width": 8 + }, + "locations": [], + "warnmsg": [] + } + }, + "params-typed-scalars": { + "args": { + "args": { + "count": 42, + "flag": true + }, + "defaulted": [], + "err": false, + "errmsg": [], + "warnmsg": [] + }, + "initargs": { + "count": 42, + "default": [], + "err": false, + "errmsg": [], + "flag": true, + "warnmsg": [] + } + } +} \ No newline at end of file diff --git a/tests/golden/inittypes.json b/tests/golden/inittypes.json new file mode 100644 index 0000000..7e97992 --- /dev/null +++ b/tests/golden/inittypes.json @@ -0,0 +1,355 @@ +{ + "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": { + "comment": "Heading of the content block, including a preheading and content element.", + "optional": false, + "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": { + "default": false, + "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.", + "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, + "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.", + "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.", + "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 diff --git a/tests/golden/nesting.json b/tests/golden/nesting.json new file mode 100644 index 0000000..6ca55c7 --- /dev/null +++ b/tests/golden/nesting.json @@ -0,0 +1,286 @@ +{ + "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, + "errmsg": [], + "heading": { + "align": "start", + "arrangement": "above", + "size": 4, + "width": 8 + }, + "locations": [], + "warnmsg": [] + } + }, + "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, + "errmsg": [], + "heading": { + "align": "start", + "arrangement": "above", + "size": 4, + "width": 8 + }, + "locations": [ + { + "instructions": [ + { + "description": "Use lot B", + "title": "Parking" + } + ], + "mode": false, + "title": "Office" + } + ], + "warnmsg": [] + } + }, + "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, + "errmsg": [], + "heading": { + "align": "center", + "arrangement": "above", + "size": 4, + "title": "Hello", + "width": 8 + }, + "locations": [], + "warnmsg": [] + } + }, + "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, + "errmsg": [], + "heading": { + "align": "start", + "arrangement": "above", + "size": 4, + "title": "Hello", + "width": 8 + }, + "locations": [], + "warnmsg": [] + } + }, + "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, + "errmsg": [], + "heading": { + "align": "start", + "arrangement": "above", + "size": 4, + "title": "Hello", + "typo-key": "oops", + "width": 8 + }, + "locations": [], + "warnmsg": [ + "[test-nested] argument 'heading': unsupported attribute 'typo-key'" + ] + } + }, + "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, + "errmsg": [], + "heading": { + "align": "42", + "arrangement": "above", + "size": 4, + "title": "Hello", + "width": 8 + }, + "locations": [], + "warnmsg": [ + "[test-nested] argument 'heading.align': unexpected value '42'" + ] + } + }, + "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, + "errmsg": [], + "heading": { + "align": "start", + "arrangement": "above", + "size": 4, + "width": 8 + }, + "locations": [], + "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..1d042a8 --- /dev/null +++ b/tests/golden/options.json @@ -0,0 +1,148 @@ +{ + "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, + "errmsg": [ + "[test-options] argument 'size': value '11' out of range [1, 10]" + ], + "warnmsg": [] + } + }, + "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, + "errmsg": [ + "[test-options] argument 'size': value '0' out of range [1, 10]" + ], + "warnmsg": [] + } + }, + "range-inside": { + "args": { + "args": { + "mode": "auto", + "size": 5 + }, + "defaulted": [ + "mode" + ], + "err": false, + "errmsg": [], + "warnmsg": [] + }, + "initargs": { + "default": [ + "mode" + ], + "err": false, + "errmsg": [], + "mode": "auto", + "size": 5, + "warnmsg": [] + } + }, + "select-default": { + "args": { + "args": { + "mode": "auto", + "size": 4 + }, + "defaulted": [ + "mode", + "size" + ], + "err": false, + "errmsg": [], + "warnmsg": [] + }, + "initargs": { + "default": [ + "mode", + "size" + ], + "err": false, + "errmsg": [], + "mode": "auto", + "size": 4, + "warnmsg": [] + } + }, + "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, + "errmsg": [ + "[test-options] argument 'mode': unexpected value 'bogus'" + ], + "warnmsg": [] + } + }, + "select-valid": { + "args": { + "args": { + "mode": "manual", + "size": 4 + }, + "defaulted": [ + "size" + ], + "err": false, + "errmsg": [], + "warnmsg": [] + }, + "initargs": { + "default": [ + "size" + ], + "err": false, + "errmsg": [], + "mode": "manual", + "size": 4, + "warnmsg": [] + } + } +} \ No newline at end of file diff --git a/tests/golden/positional.json b/tests/golden/positional.json new file mode 100644 index 0000000..3e33014 --- /dev/null +++ b/tests/golden/positional.json @@ -0,0 +1,64 @@ +{ + "both-positions": { + "args": { + "args": { + "kind": "beta", + "name": "alpha" + }, + "defaulted": [], + "err": false, + "errmsg": [], + "warnmsg": [] + }, + "initargs": { + "default": [], + "err": false, + "errmsg": [], + "kind": "beta", + "name": "alpha", + "warnmsg": [] + } + }, + "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, + "errmsg": [], + "kind": "beta", + "name": "alpha", + "warnmsg": [ + "[test-required] unsupported argument at index 2 (value: 'gamma')" + ] + } + }, + "first-position-only": { + "args": { + "args": { + "name": "alpha" + }, + "defaulted": [], + "err": false, + "errmsg": [], + "warnmsg": [] + }, + "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..a47fda8 --- /dev/null +++ b/tests/golden/required.json @@ -0,0 +1,124 @@ +{ + "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, + "errmsg": [ + "[test-required] unsupported argument 'bogus-one'", + "[test-required] unsupported argument 'bogus-two'", + "[test-required] argument 'name': expected value" + ], + "warnmsg": [] + } + }, + "group-filter-skips-partial-arg": { + "args": { + "args": { + "name": "alpha" + }, + "defaulted": [], + "err": false, + "errmsg": [], + "warnmsg": [] + }, + "initargs": { + "default": [], + "err": false, + "errmsg": [], + "name": "alpha", + "warnmsg": [] + } + }, + "group-partial-requires-both": { + "args": { + "args": { + "name": "alpha" + }, + "defaulted": [], + "err": true, + "errmsg": [ + "[test-required] argument 'partial-only': expected value" + ], + "warnmsg": [] + }, + "initargs": { + "default": [], + "err": true, + "errmsg": [ + "[test-required] argument 'partial-only': expected value" + ], + "warnmsg": [] + } + }, + "no-group-requires-all": { + "args": { + "args": { + "name": "alpha" + }, + "defaulted": [], + "err": true, + "errmsg": [ + "[test-required] argument 'partial-only': expected value" + ], + "warnmsg": [] + }, + "initargs": { + "default": [], + "err": true, + "errmsg": [ + "[test-required] argument 'partial-only': expected value" + ], + "warnmsg": [] + } + }, + "required-missing-errors": { + "args": { + "args": { + "flag": true + }, + "defaulted": [], + "err": true, + "errmsg": [ + "[test-required] argument 'name': expected value" + ], + "warnmsg": [] + }, + "initargs": { + "default": [], + "err": true, + "errmsg": [ + "[test-required] argument 'name': expected value" + ], + "warnmsg": [] + } + }, + "required-provided": { + "args": { + "args": { + "name": "alpha" + }, + "defaulted": [], + "err": false, + "errmsg": [], + "warnmsg": [] + }, + "initargs": { + "default": [], + "err": false, + "errmsg": [], + "name": "alpha", + "warnmsg": [] + } + } +} \ No newline at end of file diff --git a/tests/golden/schema.json b/tests/golden/schema.json new file mode 100644 index 0000000..e574631 --- /dev/null +++ b/tests/golden/schema.json @@ -0,0 +1,854 @@ +{ + "bookshop-blueprint": { + "schema": { + "err": false, + "errmsg": [], + "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, + "reflects": [ + "template.HTML" + ], + "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, + "reflects": [ + "hstring.RenderedString", + "hstring.HTML", + "template.HTML" + ], + "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, + "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'" + ], + "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, + "reflects": [ + "hstring.RenderedString", + "hstring.HTML", + "template.HTML" + ], + "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": false, + "errmsg": [], + "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, + "reflects": [ + "template.HTML" + ], + "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, + "reflects": [ + "hstring.RenderedString", + "hstring.HTML", + "template.HTML" + ], + "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, + "reflects": [ + "template.HTML" + ], + "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, + "reflects": [ + "hstring.RenderedString", + "hstring.HTML", + "template.HTML" + ], + "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, + "reflects": [ + "hstring.RenderedString", + "hstring.HTML", + "template.HTML" + ], + "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