Follow-up to #13, which introduces source: and implements source: context. This one covers the third binding: props passed by a calling template.
The gap
28 of the 70 components in eprukaz2025 are internal or utility components — button, picture, header, footer, card-teaser. They have no <name>.yaml, no acf.json, and no fields: annotation anywhere. Their contract exists only implicitly, in whatever each call site happens to pass:
{{ component_button({
title: content.button.title,
url: content.button.url,
icon: 'arrow-right',
}) }}
Measured on the most-used component in the project
button, 14 call sites across static/templates/**/*.twig, compared against what button.twig actually reads:
| prop |
passed by |
read by template |
reading |
title |
14 / 14 |
yes |
required in practice |
url |
14 / 14 |
yes |
required in practice |
target |
14 / 14 |
yes |
required in practice |
icon |
14 / 14 |
yes |
required in practice |
wrapper_classes |
8 / 14 |
yes |
optional |
link |
3 / 14 |
no |
dead prop |
wrapper_id |
0 / 14 |
yes |
project-wide convention |
Three call sites pass link:, which button.twig never reads — price-list, price-list-grid and one more. Leftover from a refactor that split link into url / title / target. Twig discards unknown keys silently, so it has shipped unnoticed.
That is the whole argument: for props there is no such thing as a typo that fails. Rename a prop in the template, miss one of fourteen call sites, and the symptom is an empty spot on a page — possibly weeks later.
The counter-example that shapes the design
wrapper_id is read by button.twig and passed by zero call sites — but it is not dead. It is a project-wide passthrough convention for the Gutenberg anchor, read by 62 of 70 components. A naive "declared but never passed → finding" rule would flag it 62 times and the linter would be turned off within a day.
So the contract must distinguish three states, not two:
- required — every call site passes it; absence is a defect
- optional — some pass it; absence is fine
- inherited — no call site passes it; supplied by the render pipeline (
wrapper_id, wrapper_classes from the block wrapper)
A rule that only knows "declared" and "passed" cannot express wrapper_id and will produce noise.
Sketch
name: Button
kind: element
fields:
title: { type: text, source: parent, required: true }
url: { type: url, source: parent, required: true }
target: { type: text, source: parent }
icon: { type: text, source: parent }
wrapper_classes: { type: text, source: inherited }
wrapper_id: { type: text, source: inherited }
No acf.json is produced — zero fields with source: acf, per #13's rule.
Why this is harder than context and should wait
context has exactly one producer (timber_context()), so validation is a static lookup. parent has as many producers as there are call sites, and checking it means parsing component_<name>({…}) invocations across every template — including calls inside loops, conditionals, and {% embed %} blocks, where a static parse gets unreliable fast.
Concretely, before building this: decide whether the linter reports a missing required prop (needs reliable call-site parsing, high false-positive risk) or only an unknown prop passed (the link: case — needs only the declared set, and is where the real value is). The second is much cheaper and catches the defect that actually shipped here.
Recommend doing #13 first, running it on a real project, and only then judging whether call-site parsing earns its cost.
Reference
https://claude.ai/code/artifact/58e4b426-2027-4027-8d77-6c5124c56f90
Follow-up to #13, which introduces
source:and implementssource: context. This one covers the third binding: props passed by a calling template.The gap
28 of the 70 components in
eprukaz2025are internal or utility components —button,picture,header,footer,card-teaser. They have no<name>.yaml, noacf.json, and nofields:annotation anywhere. Their contract exists only implicitly, in whatever each call site happens to pass:{{ component_button({ title: content.button.title, url: content.button.url, icon: 'arrow-right', }) }}Measured on the most-used component in the project
button, 14 call sites acrossstatic/templates/**/*.twig, compared against whatbutton.twigactually reads:titleurltargeticonwrapper_classeslinkwrapper_idThree call sites pass
link:, whichbutton.twignever reads —price-list,price-list-gridand one more. Leftover from a refactor that splitlinkintourl/title/target. Twig discards unknown keys silently, so it has shipped unnoticed.That is the whole argument: for props there is no such thing as a typo that fails. Rename a prop in the template, miss one of fourteen call sites, and the symptom is an empty spot on a page — possibly weeks later.
The counter-example that shapes the design
wrapper_idis read bybutton.twigand passed by zero call sites — but it is not dead. It is a project-wide passthrough convention for the Gutenberg anchor, read by 62 of 70 components. A naive "declared but never passed → finding" rule would flag it 62 times and the linter would be turned off within a day.So the contract must distinguish three states, not two:
wrapper_id,wrapper_classesfrom the block wrapper)A rule that only knows "declared" and "passed" cannot express
wrapper_idand will produce noise.Sketch
No
acf.jsonis produced — zero fields withsource: acf, per #13's rule.Why this is harder than
contextand should waitcontexthas exactly one producer (timber_context()), so validation is a static lookup.parenthas as many producers as there are call sites, and checking it means parsingcomponent_<name>({…})invocations across every template — including calls inside loops, conditionals, and{% embed %}blocks, where a static parse gets unreliable fast.Concretely, before building this: decide whether the linter reports a missing required prop (needs reliable call-site parsing, high false-positive risk) or only an unknown prop passed (the
link:case — needs only the declared set, and is where the real value is). The second is much cheaper and catches the defect that actually shipped here.Recommend doing #13 first, running it on a real project, and only then judging whether call-site parsing earns its cost.
Reference
https://claude.ai/code/artifact/58e4b426-2027-4027-8d77-6c5124c56f90