Skip to content

feat(a11y): forward a heading level to section titles - #193

Merged
markdumay merged 7 commits into
gethinode:mainfrom
LeonarddeR:feat/section-title-heading-level
Aug 5, 2026
Merged

feat(a11y): forward a heading level to section titles#193
markdumay merged 7 commits into
gethinode:mainfrom
LeonarddeR:feat/section-title-heading-level

Conversation

@LeonarddeR

@LeonarddeR LeonarddeR commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Companion to gethinode/hinode — "give content blocks a heading level", which is phase 2 of
gethinode/hinode#1519. That pull request teaches the shared section title partial to render a real
heading; this one makes the blocks ask for it. Neither changes what a page looks like on its own.

What is wrong today

A page assembled from content blocks has no headings at all — not even an h1. This module emits
no <h1> through <h6> anywhere. Twelve Bookshop wrappers render a section title, plus the hero
and preview partials, and every one of them produces a <div>.

The hero partial did forward hinode's old use-title boolean, but the hero wrapper never passed
one in, so it was always off. And a boolean could only ever have produced an h1, which is not
useful on a page that stacks a dozen titled blocks.

The change

page/blocks.html takes a heading-level argument from the page template that renders it. When
the page supplies one, the block that opens the page receives that level and every block after it
becomes a subsection. A block that sets heading_level in its own front matter always wins.

When the page passes no level — which is what happens with a version of hinode that predates the
companion change — nothing is injected and every block keeps rendering a <div>, exactly as
before.

The opening block is found by scanning the block list, rather than by watching which block
renders first. Hugo chooses when a page's content is rendered, so render order is not a reliable
signal. The scan takes the first entry that has a heading title and can be relied on to show it.
A block that suppresses its own heading with a level of 0 is passed over, and so is one whose
component does not declare a heading-level argument in its schema. A block that hides itself
when its section is empty (hide_empty) ranks last: a later titled block wins over it, and it
opens the page only when no other candidate exists. When such a block does render ahead of the
chosen opener, its title shows as a subsection above the page's h1 — knowing at scan time
whether it will render would mean duplicating each wrapper's page-selection logic. For the same
reason, when every titled block hides itself the first one is chosen even if it turns out empty,
and the page has no h1 when a later one renders; gethinode/hinode#2121 tracks a render-aware
scan for that case. A block that
pins a non-zero level in its front matter keeps it, and takes the opening slot with it.

Each wrapper forwards the level to the section title partial, and heading-level is declared in
every affected component's schema so it validates and shows up in the generated argument tables.
Two components stay out: separator renders no heading at all, and releases renders its title
through hinode's timeline partial, which does not take a heading level yet. The schema check
keeps the injected level away from both.

Files

  • layouts/partials/page/blocks.html — works out the opening block and injects the level
  • Fifteen component wrappers — about, approach, articles, cards, contact-form, cta,
    faq, featured, heading, list, menu, panels, team, testimonials, video-message
  • layouts/partials/assets/hero.html, assets/preview.html and assets/contact.html, plus
    their wrappers and the three matching structure files
  • One schema entry per component sidecar
  • The example site's hinode dependency, raised to v3.19.1 so the build understands heading levels
  • CLAUDE.md — records the sidecar schema contract for heading injection

Verification

Built against the companion hinode branch through a Hugo workspace, using hinode's example site,
which imports this module.

Before the two changes, 7 of 111 pages had no h1 — the home page in three languages, the team
page in two, and the two documentation landing pages. After, every page has exactly one h1, no
page has two, and no page skips a level. Comparing heading outlines page by page, exactly those 7
changed and the other 104 are identical.

Rendered geometry was measured through Claude in Chrome at a matched 1280×900 viewport, recording
position, size, font size, weight, line height and margins for every text-bearing element inside
<main>. Across the nine pages that could change, zero elements differ and every page reports an
identical scroll height.

The example site builds with no errors and no argument warnings.

After the review fixes, the same comparison holds: the minified build of hinode's example site is
byte-identical on every page except the three component documentation pages whose argument tables
gained a row, plus the search index that carries their text. Throwaway pages covered the review
scenarios: a contact-form with an illustration opening a page renders its hero title as h1; an
empty hidden articles block passes the h1 to the next titled block; a block that suppresses its
heading does the same while keeping its styled div; a block that pins its own level keeps it,
leaving the page without an h1 by the author's choice. This module's own example site now
builds cleanly as well: every demo page carries exactly one h1, except the releases demo, whose
title stays a div until the timeline partial learns heading levels.

The second review round changed the lead scan from skipping hide_empty blocks to ranking them
last. Three throwaway pages verified the ranking: a page whose only block is a hide_empty
article list renders its title as h1; an empty hide_empty block ahead of a titled block
renders nothing while the h1 falls through to the next block; a hide_empty block with content
renders as a subsection above the next block's h1. With the fixtures removed, the example site
builds byte-identical to the previous revision.

Note on the schemas

heading-level is declared in each component sidecar rather than once in a shared place, because
the shared argument definitions live in mod-utils. Moving it there and reducing these to bare keys
would be a worthwhile follow-up.

It is deliberately not added to the Bookshop blueprints, so the visual editor does not gain an
empty numeric field on every component for an option most authors never need to set. Pages that do
need it can set heading_level in front matter.

Every Bookshop wrapper rendered its section title as a plain div, so a page assembled from
content blocks had no headings at all — not even an h1. Hinode's `assets/section-title.html`
can now render a real heading, but only when a caller asks for a level.

Work out which block opens the page and hand it the level the page asked for; every block
after it becomes a subsection. A block that sets `heading_level` itself always wins. When the
page passes no level, nothing is injected and the blocks keep rendering divs, so a caller
that has not been updated is unaffected.

The opening block is found by scanning the block list for the first entry with a heading
title, rather than by watching which block renders first. Hugo chooses when a page's content
is rendered, so render order is not a reliable signal.

Forward the level through the twelve wrappers that render a section title, plus the hero and
preview partials, and declare it in each component's schema.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@markdumay

Copy link
Copy Markdown
Contributor

Review notes

Verified against hinode v3.19.0 by building hinode's example site with this branch resolved through a workspace use and --ignoreVendorPaths, gating on the built artifact rather than on config output.

What holds up

  • The headline claim is true. Every non-redirect page across all three languages now has exactly one h1, and no page skips a level. (62 pages report a non-h1 first heading, but that is the pre-existing <h5 class="offcanvas-title"> sidebar element, present in the base build too — unrelated to this change.)
  • The static lead-block scan is the right call. Deriving the opening block from the block list rather than from render order genuinely sidesteps Hugo's content-render timing.
  • The front-matter override works: heading_level: 3 renders <h3>, heading_level: 0 renders a div, unset takes the injected level.
  • Backward compatibility holds — with no level passed, nothing is injected and blocks keep rendering divs.
  • preview and hero are wired consistently, and leaving the *.bookshop.yml blueprints untouched matches the stated intent.

blocks.html never verifies that the lead block actually emits its level

The lead is designated from front matter, but nothing checks that the designated block renders the heading it was handed. Three ways a page still ends up with no h1, all reproduced on clean builds with no errors:

A. contact-form with an illustration — a plain omission.

contact-form.hugo.html has two branches. The else branch received heading-level; the .illustration branch routes through assets/hero.html and does not pass it, even though hero.html now reads $args.headingLevel. With such a block as the lead:

<div id="contact-us-first-block" class="display-4 text-body pt-1">   <- lead, should be h1
<h2 id="second-block" class="display-4 text-body pt-1 mt-0 mb-0">

Page h1 count: 0. One-line fix — add "heading-level" (or .heading_level (index . "heading-level")) to the assets/hero.html call around contact-form.hugo.html:63.

B. A lead block that declines to render.

In articles.hugo.html and list.hugo.html the section title sits inside {{ if or (gt (len $pages) 0) (not .hide_empty) }}. An articles block with hide_empty: true over an empty section renders nothing at all, so the level it was assigned evaporates and only the second block's h2 survives. Page h1 count: 0.

This one is structural — the scan reads front matter and cannot know a block will bail. Excluding hide_empty blocks from lead selection is the cheapest mitigation; otherwise it is worth documenting as a known limitation.

C. Overriding the lead block's own level.

A first block carrying heading_level: 3 keeps its 3 while every later block still gets 2, so no h1 exists anywhere on the page. Arguably "you asked for it", and the schema does say a block's own level wins — but it shares a root cause with A and B, and nothing warns.

Given the framing in the description — "a page assembled from content blocks has no headings at all, not even an h1" — these three paths still produce exactly that. A cheap catch-all would be for blocks.html to fall through to the next titled block, or to warn when the assigned lead cannot be shown to render.

Minor

  • (or .heading_level (index . "heading-level")) relies on 0 being falsy, so heading_level: 0 falls through to the kebab key and lands on nil. That reaches the right result (a div) for the wrong reason, and becomes fragile if hinode's nil-means-div rule ever changes.
  • test here is just pnpm build, so none of this is pinned. hinode's tests/templates cannot host it either, since page/blocks.html is a placeholder in that repo — which makes the example-site fixture discussed on feat(a11y): give content blocks a heading level hinode#2116 the only viable home. Worth landing alongside the dependency bump.

Verdict

The design is sound and the main path is verified working end to end. A is a clear bug and one line to fix. B and C deserve a decision — fix, or document as known limitations.

LeonarddeR and others added 2 commits August 4, 2026 12:31
The lead scan and level injection consult the component schema, so
blocks whose component cannot render a leveled title (separator,
releases) are passed over instead of failing validation. Blocks that
hide themselves when their section is empty or that suppress their own
heading no longer take the opening slot. The about, cta and featured
wrappers forward the heading level through their hero and contact
partials, the contact-form illustration branch does the same, and every
wrapper reads an explicit level of 0 by key presence instead of relying
on 0 being falsy. The example site builds against hinode v3.19.1, which
understands section title heading levels.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@LeonarddeR

Copy link
Copy Markdown
Contributor Author

Addressed in a8b1e3e.

A — contact-form with an illustration. Fixed. The illustration branch now forwards heading-level to assets/hero.html, the same way the plain branch forwards it to the section title. A page that opens with such a block renders its hero title as h1.

B — a lead block that declines to render. Fixed in the scan, per your suggestion. A block that hides itself when its section is empty cannot promise it will render, so the scan passes over any block carrying hide_empty and assigns the opening level to the next titled block. One consequence to be aware of: when a hide_empty block does have content, its title now renders as a subsection while the h1 sits on the first block after it.

C — a lead block overriding its own level. Split in two. A block that sets heading_level: 0 declares it renders no heading, so the scan passes over it and the next titled block opens the page. A block that pins a non-zero level keeps it and the page renders no h1; that remains the author's call, as the schema comments state.

The 0-falls-through lookup. Fixed at every call site. The wrappers now test for the key's presence instead of relying on 0 being falsy, so an explicit 0 reaches the section title as the documented "render a div" value rather than as nil.

What the dependency bump surfaced. This module's own example site needed hinode v3.19.1 to build at all, and that build exposed five components the hinode example site never exercises. Three were plain omissions of the same kind as A: about and featured route through the hero partial, and cta routes through the contact partial into the hero. All three now forward the level and declare it in their schemas. The other two cannot render a leveled title: separator has no heading, and releases renders its title through hinode's timeline partial, which takes no level yet. The scan and the injection now consult the component's sidecar schema, so a block whose component does not declare heading-level is passed over instead of failing validation. Teaching the timeline partial a heading level would be a hinode follow-up; until then a page that opens with a releases block keeps its div title.

Verification. Rebuilt hinode's example site against this branch through a workspace use. All 111 pages keep exactly one h1 and no page skips a level, with no errors and no argument warnings. Four throwaway pages reproduced the review scenarios:

  • contact-form with an illustration as opening block: renders <h1>, the next block <h2>.
  • an empty hide_empty articles block first: renders nothing, the next titled block renders <h1>.
  • an opening candidate with heading_level: 0: renders a div with the exact pre-existing styling, the next block renders <h1>.
  • an opening block pinned to heading_level: 3: renders <h3>, the next block <h2>, no h1 — unchanged, documented.

The minified build of hinode's example site is byte-identical to the previous revision on every page except the three component documentation pages whose argument tables gained the heading-level row, plus the search index that carries their text. This module's own example site now builds cleanly as well: every demo page carries exactly one h1, no page has two, and no page skips a level — except the releases demo, whose title stays a div for the reason above.

Pinning these scenarios. Nothing in this module can host such a test today, as you note. The example-site fixture discussed on gethinode/hinode#2116 is the viable home; deferred to land alongside the dependency bump.

@markdumay

Copy link
Copy Markdown
Contributor

Rebuilt hinode's example site against a8b1e3e (hinode v3.19.1, workspace use, --ignoreVendorPaths). Your headline numbers reproduce exactly: 111 pages, every one with exactly one h1, none skipping a level.

Re-ran the four review scenarios plus three more. Six of seven match your description:

fixture title elements in <main> h1
contact-form + illustration as lead h1#contact-lead, h2#second-block 1 ✓
empty hide_empty articles first h1#second-block 1 ✓
lead pinned to heading_level: 0 div#zero-lead, h1#second-block 1 ✓
lead pinned to heading_level: 3 h3#pinned-lead, h2#second-block 0 — unchanged, documented ✓
releases as lead h1#second-block 1 ✓

A, B and the zero case are genuinely fixed.

The hide_empty skip is over-broad — one page shape still loses its h1

Skipping hide_empty blocks in lead selection is a static over-approximation: it assumes such a block won't render, when usually it does. Two consequences, both reproduced:

A page whose only titled block is a hide_empty block has no h1 at all.

content_blocks:
  - _bookshop_name: articles
    hide_empty: true
    input: { section: blog }
    heading: { title: Only Block Is HideEmpty }

Renders 12 article links — real content, a real title — and its only heading is <h2 id="only-block-is-hideempty" class="display-4 text-body pt-1 mt-0 mb-0">. Zero h1. That is the defect this pull request exists to remove, and it is not an exotic shape: a section landing page consisting of one filtered article list is about as ordinary as content blocks get. There is no later candidate to fall back to, so the skip simply drops the heading.

The consequence you flagged is the common case, not the corner. With a hide_empty block that does render followed by another block, the outline is now h2#nonempty-hideempty-lead then h1#second-block — the h2 precedes the h1. Before a8b1e3e that page produced h1 then h2, in the right order. So the hardening improves the empty case and degrades the non-empty one, which is the more likely of the two.

Suggested narrowing: keep hide_empty blocks eligible as lead but rank them last — prefer a non-hide_empty titled block, and fall back to a hide_empty one when no other candidate exists. That preserves the empty-lead fix (a later candidate still wins) and closes the single-block case. The ordering in the mixed case would remain, and is a fair thing to document as a tradeoff — knowing at scan time whether a block will render means duplicating each wrapper's page-selection logic, which is not worth it.

site.Data is deprecated

The sidecar lookup uses site.Data.structures.components. On the pinned Hugo 0.164 that emits:

WARN  deprecated: .Site.Data was deprecated in Hugo v0.156.0 and will be removed in a future release. Use hugo.Data instead.

I confirmed it on a minimal site rather than inferring it from hinode's build, which already carries the warning from elsewhere. Two new call sites here, both inside range loops over the block list, on an API already scheduled for removal — worth switching to hugo.Data now.

Minor

  • The sidecar is resolved twice per block, once in the scan loop and once in the injection loop. Hoisting site.Data.structures.components (or hugo.Data...) to a single lookup above both loops would be tidier, and this ecosystem has form for watching per-block template cost.
  • Gating injection on schema presence means a component that renders a section title but forgets its heading-level entry silently loses headings rather than failing loudly. That is the right call for separator and releases, but it makes the sidecar the real contract — worth a line in the component authoring notes so the next component does not quietly regress.

Everything else here looks good, and the hugo.Data swap and the lead-ranking change are both small.

@markdumay

Copy link
Copy Markdown
Contributor

Correction on the site.Data point — disregard the "worth switching to hugo.Data now" recommendation. gethinode/hinode#2049 already tracks this deprecation and explicitly decides against fixing it piecemeal.

hugo.Data arrived in Hugo v0.156.0. This module advertises module.hugoVersion.min = "0.147.6", so adopting it here would silently raise the real requirement above the declared floor — precisely the trap #2049 is holding the whole batch back to avoid. The issue's plan is to swap all eight deprecations in one pass across hinode and the affected mod-* repos once the floor moves to at least v0.158.0. site.Data is on that list already, via the mod-utils layer (utilities/GetMenu.html, utilities/ArgsSchema.html).

What does still hold is narrower: a8b1e3e introduces the first site.Data call sites in this module — there were none before. So the change is consistent with prevailing practice and correct to leave as is, but it adds mod-blocks to the set of repos that batch migration has to sweep. Worth adding this module to the source column of #2049's table so it isn't missed when the floor moves.

The hoist-the-lookup nit is unaffected and independent of which API ends up being used.

Everything else in my previous comment stands, in particular the hide_empty page shape that still ends up with no h1.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@LeonarddeR

Copy link
Copy Markdown
Contributor Author

Addressed in e6d6058.

The over-broad hide_empty skip. Adopted your narrowing. The scan now ranks instead of excludes: a titled hide_empty block no longer disqualifies itself, it only loses to a later titled block without hide_empty. When no other candidate exists, the first such block leads. Your single-block page shape renders its title as h1 again, and the empty-lead fix is preserved because a later candidate still wins. The mixed shape keeps its ordering — a rendering hide_empty block shows h2 above the following block's h1 — recorded in the scan comment and the PR description as the tradeoff for not duplicating each wrapper's page-selection logic at scan time.

site.Data. Left as is, per your correction. mod-blocks joining the #2049 sweep is flagged in a comment on that issue, since its body is yours to edit.

The double sidecar resolution. The components map is hoisted above both loops; each loop now resolves only the per-block sidecar from it.

The silent schema gate. Recorded as a constraint in the repo's authoring notes (CLAUDE.md): a component that renders a section title must declare heading-level in its sidecar, or the dispatcher passes it over and its titles stay divs. The same note now shows the presence-test lookup for arguments where 0 is meaningful, which the notes previously gave as the or-pattern the first round corrected.

Verification. Three throwaway pages in this module's example site, built against hinode v3.19.1:

  • a sole hide_empty articles block over a non-empty section: renders <h1 id=only-block-is-hideempty> — the shape you reproduced, fixed.
  • an empty hide_empty lead ahead of a titled block: renders nothing, the next block renders h1.
  • a rendering hide_empty lead ahead of a titled block: renders h2 above the next block's h1 — the documented tradeoff, unchanged.

With the fixtures removed, the minified example-site build is byte-identical to a8b1e3e, and the sweep reports every demo page with exactly one h1 and no level skips, the releases demo excepted as before.

@markdumay

Copy link
Copy Markdown
Contributor

Rebuilt against e6d6058 (hinode v3.19.1, workspace use). The ranking change does what you describe, and the sweep reproduces: 111 real pages, exactly one h1 each, no level skips.

fixture title elements in <main> h1
sole rendering hide_empty block h1#only-block-is-hideempty 1 — fixed
empty hide_empty lead, then a titled block h1#second-block 1 ✓
rendering hide_empty lead, then a titled block h2#nonempty-hideempty-lead, h1#second-block 1 — documented tradeoff ✓
contact-form + illustration as lead h1#contact-lead, h2#second-block 1 ✓
lead pinned to heading_level: 0 div#zero-lead, h1#second-block 1 ✓
lead pinned to heading_level: 3 h3#pinned-lead, h2#second-block 0 — documented ✓

The shape I reported is closed.

One residue, same class, narrower

The ranking resolves hide_empty against non-hide_empty, but not hide_empty against hide_empty. When every titled candidate carries it, $fallback takes the first one — which may be exactly the one that renders nothing:

content_blocks:
  - _bookshop_name: articles          # empty section -> renders nothing
    hide_empty: true
    input: { section: nonexistent }
    heading: { title: First Empty }
  - _bookshop_name: articles          # renders 12 articles
    hide_empty: true
    input: { section: blog }
    heading: { title: Second Nonempty }

Output is h2#second-nonempty alone — zero h1. Two filtered lists both guarded by hide_empty, the first currently empty, is a plausible enough page (a "featured" list above an "all posts" list, say).

I don't think this should block. Picking the right one statically means knowing which sections are empty at scan time, which is the wrapper-logic duplication you already ruled out, and I agree with that call. It reads as one more line in the tradeoff note you added — the fallback picks the first hide_empty candidate, which loses the h1 if that block turns out empty and a later hide_empty block renders.

For a genuinely empty sole block (hide_empty over an empty section, nothing else on the page) there is no h1 either, but nothing renders at all, so there is nothing to promote — that one is fine as is.

Everything else from my earlier comments is resolved: the hoisted components map, the schema-gate note in the authoring docs, and site.Data left alone per the #2049 batch. Thanks for the iterations — from my side this is good to go once you've decided whether the two-hide_empty case is worth a sentence or a follow-up.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@LeonarddeR

Copy link
Copy Markdown
Contributor Author

Documented and tracked, per both routes.

The sentence. 39dac89 adds the two-hide_empty case to the scan comment's tradeoff note, and the PR description now carries it in the same paragraph as the subsection-above-h1 case.

The follow-up. gethinode/hinode#2121 (mod-blocks has issues disabled). It records one direction that avoids the wrapper-logic duplication we both ruled out: all three hide_empty components fetch their pages through the shared assets/live-pages.html, so the scan could ask that same partial whether a candidate has pages and pass over the ones that render nothing. Costs a second selection pass at scan time, so left for its own change.

The commit is comment-only; the build is unchanged.

Resolves the exampleSite module conflict by taking main's hinode v3.19.2
(v3.19.1 on this branch); the patch is sidebar-only and does not touch
section titles or blocks. Both files are now byte-identical to main.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@markdumay

Copy link
Copy Markdown
Contributor

Stepping back from the behaviour, which is now in good shape, to where heading-level is declared. Your note in the original description — that the shared definitions live in mod-utils and moving it there "would be a worthwhile follow-up" — is right, and having looked at how the argument system resolves, I think it should happen before this lands rather than after.

How the three layers resolve

ArgsSchema.html builds a component's schema like this:

$arguments := site.Data.structures._arguments.arguments   # canonical definitions (mod-utils)
$raw       := <component>.bookshop.blueprint              # the accepted argument surface
$raw        = merge $raw <component>.arguments            # sidecar, overrides only

then per key $def := index $arguments $normKey merged with the local override. Its own comment puts it plainly: "merge the global definition with inline overrides from the structure file or blueprint."

So _arguments.yml is where an argument is defined, the *.bookshop.yml blueprint is what declares a component accepts it, and the *.yml sidecar carries comment and example — the fields mod-llm reads in index.llmscomponents.json.

Two conventions worth noting, both checked rather than assumed:

  • _arguments.yml contains no release: fields at all; version metadata belongs to the local layer. The idiomatic shape for adopting a shared argument is a release-only override, and there are 98 across hinode's structure files — card-group.yml alone has twelve.
  • heading-class joins an existing family: bg-class, icon-class, section-class and figclass are already shared arguments.

Where this lands today

use-title, the boolean heading-level replaces, is a single five-line entry in _arguments.yml referenced by bare key everywhere. heading-level is not in _arguments.yml at all — the same six-line block appears fourteen times across this pull request and hinode v3.19.0.

It also put schema where there was none. On main, cards.yml is only comment: + example:; several sidecars gain their first-ever arguments: block here. That matters beyond tidiness, because the dispatch gate added in the third round reads exactly that layer:

$sidecar := index (index $components $name) $name
$leveled := isset ($sidecar.arguments) "heading-level"

A block renders a heading or a div depending on the contents of the file that feeds mod-llm. I should have caught that when I reviewed that round instead of only checking that it worked.

On keeping the field out of the CMS

Your reason for skipping the blueprints was to avoid an empty numeric field on every component in the visual editor. That is solvable in the consuming site rather than by omitting the declaration: hidden: true on the generated _inputs entry, which gethinode.com already does for eight keys — bg-class/bg_class, section-class/section_class and overlay-mode/overlay_mode, in both their kebab and snake forms. Those are the same family as heading-class, so the treatment carries over directly. setup-cloudcannon-cms generates inputs.config.yml from _arguments.yml, so the entry flows through automatically once it is defined there.

Prep work, already open

Verified end to end through a workspace use: all six argument shapes render byte-identically to shipped v3.19.0, including the one that would break first — an unset level staying nil so the use-title fallback still produces its h1.

Suggested sequencing

  1. mod-utils#361 merges and releases
  2. hinode#2122 bumps and merges, hinode releases
  3. this pull request drops the fourteen sidecar arguments: blocks, adds heading_level: as a bare key to each consuming blueprint, and repoints the dispatch gate at the blueprint

Happy to do step 3 as a patch against your branch once the first two land, so it is not extra work for you — nothing here needs re-doing, it is the same design declared one layer down. The CMS-field question is the one place your judgement should override mine if you think hidden: true in consuming sites is worse than leaving the argument out of the blueprint.

heading-level was defined inline in seventeen component sidecars and three
partial structures. Common arguments belong in mod-utils _arguments.yml,
where use-title -- the boolean it replaces -- has always lived; consumers
reference them by bare key.

- sidecars drop the definition; several return to comment + example, which
  is all they carried before this branch
- the three partial structures keep a release-only override, the shape 98
  arguments across hinode's structures already use
- each consuming component declares heading_level in its blueprint, which
  is what states a component accepts an argument
- the lead scan and the injection read the blueprint instead of the
  sidecar, so rendering no longer depends on the docs layer mod-llm reads
- bump mod-utils to v6.9.0 for the definitions, hinode to v3.19.3

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@markdumay

Copy link
Copy Markdown
Contributor

Both prerequisites are released — mod-utils v6.9.0 and hinode v3.19.3 — so I pushed the declaration change to this branch as 8661960, as offered. Nothing about the behaviour changes; it is the same design moved one layer down. Revert it if you disagree with any of it, particularly the CMS point below.

What changed

  • Seventeen sidecars drop the heading-level definition. Several return to comment + example, which is all they carried before this branch.
  • Three partial structures (hero.yml, preview.yml, contact.yml) keep a release-only override, the shape 98 arguments across hinode's structures already use.
  • Each consuming component declares heading_level in its blueprint, directly after heading, since the blueprint is what states a component accepts an argument.
  • The lead scan and the injection read the blueprint rather than the sidecar, so rendering no longer depends on the layer mod-llm reads.
  • mod-utils bumped to v6.9.0, hinode to v3.19.3.

Net: 43 insertions, 188 deletions.

On the CMS field

This is the part where your judgement should win if you disagree. Declaring heading_level in the blueprint does surface it in the visual editor, which is what you were avoiding. The consuming site can hide it: gethinode.com already does exactly this for eight keys via hidden: true on the generated _inputs entry — bg-class/bg_class, section-class/section_class and overlay-mode/overlay_mode, each in both forms. heading-class is the same family, so the pattern carries over. If you would still rather keep it out of the blueprint entirely, say so and I will reduce this to the sidecar-free half and leave the gate on something else.

Verification

  • mod-blocks' own example site: zero differing files against a pre-refactor build with identical module pins, so the change is byte-for-byte inert. 33 pages, one h1 each, no level skips, /components/releases/ still the documented exception.
  • hinode's example site through a workspace use: 111 real pages, exactly one h1 each, no skips.
  • All five review scenarios re-run against the blueprint gate and unchanged:
fixture title elements h1
contact-form + illustration as lead h1#contact-lead, h2#second-block 1
empty hide_empty lead h1#second-block 1
lead pinned to heading_level: 0 div#zero-lead, h1#second-block 1
sole rendering hide_empty block h1#only-block-is-hideempty 1
rendering hide_empty lead, then a block h2#nonempty-hideempty-lead, h1#second-block 1

releases and separator are passed over by the gate as before — neither declares heading_level, which is now visible in the blueprint rather than implied by an absent sidecar entry.

@markdumay
markdumay merged commit c342ddf into gethinode:main Aug 5, 2026
8 checks passed
@markdumay

Copy link
Copy Markdown
Contributor

🎉 This PR is included in version 2.4.0 🎉

The release is available on GitHub release

Your semantic-release bot 📦🚀

@LeonarddeR

Copy link
Copy Markdown
Contributor Author

Thanks @markdumay, this is a great accessibility win!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants