The blank line that decides whether your banner is a banner
Shipped
0.9.0 added an emitter that generates the banner at the top of a README, so that ten separately-published packages stop carrying ten hand-typed variations of the same identity line. Three of them had no brand mark at all, which meant the drift checker could not see them; a file with no generated region in it is a file the checker has nothing to say about.
Markdown turned out to be the most constrained medium this generator emits into. It has bold, it has a rule, it has a middot, and past that it has raw HTML, which brings its own problems. This guide builds the emitter and the splice, and shows the one whitespace bug that makes the whole thing render as something else entirely.
Setup: the banner as a pure function of tokens
The banner is a single line of text with a rule under it: a stamp, the publication name, the kind of document, the version, and a byline.
// masthead.mjs — the banner a README wears.
export function readmeMasthead(tokens, params, ctx) {
const version = ctx.version ?? '0.0.0';
const stamp = params.stamp ?? tokens.identity.stamp;
const name = params.brand_line ?? tokens.identity.name;
const byline = params.byline ?? tokens.identity.byline;
const kind = String(params.document_kind ?? 'Claude Code skill').toUpperCase();
if (!name) throw new Error('readme-masthead needs identity.name or params.brand_line');
const eyebrow = [
`**${stamp}**`,
String(name).toUpperCase(),
kind,
`KIT v${version}`,
byline,
].join(' · ');
// The blank line before the rule is load-bearing: `text` immediately followed
// by `---` is a setext H2 in every markdown flavour, so without it the whole
// eyebrow silently renders as a heading instead of a banner over a rule.
return `${eyebrow}\n\n---`;
}
Everything the banner needs comes from tokens or params. The publication name in particular is worth promoting to a token rather than repeating in eleven target configs, which is the hand-copying a generator exists to end.
The blank line is not formatting
The comment above the return is the whole post. CommonMark defines a setext heading as “one or more lines of text, not interrupted by a blank line, of which the first line does not have more than 3 spaces of indentation, followed by a setext heading underline”, and adds that the lines “must be such that, were they not followed by the setext heading underline, they would be interpreted as a paragraph” (CommonMark 0.31.2). The spec’s own example 104 shows the fix: put a blank line in and the dashes become a thematic break instead.
GitHub Flavored Markdown inherits this unchanged; setext headings appear in the GFM spec as core CommonMark, not as one of the extensions like tables or task lists. So this is not a quirk of one renderer to work around, it is the definition.
Prove it against the renderer that actually matters. GitHub’s API will render markdown for you, which makes this checkable in one command:
printf '**Kit** · TALLY · CLAUDE CODE SKILL · v0.9.0 · Nate Swenson\n\n---\n' > good.md
printf '**Kit** · TALLY · CLAUDE CODE SKILL · v0.9.0 · Nate Swenson\n---\n' > bad.md
echo "=== with the blank line ==="
gh api --method POST /markdown -f text="$(cat good.md)" -f mode=gfm
echo "=== without the blank line ==="
gh api --method POST /markdown -f text="$(cat bad.md)" -f mode=gfm
=== with the blank line ===
<p><strong>Kit</strong> · TALLY · CLAUDE CODE SKILL · v0.9.0 · Nate Swenson</p>
<hr>
=== without the blank line ===
<h2><strong>Kit</strong> · TALLY · CLAUDE CODE SKILL · v0.9.0 · Nate Swenson</h2>
One newline is the difference between a paragraph with a rule under it and an <h2>. In raw markdown the two files look nearly identical, a diff shows one blank line, and no linter objects. The rendered page is the only place it shows.
Splice it under the H1, and only there
A README has to open on its own name, so the banner goes under the H1 rather than above it. That gives the splicer a precise anchor to attach to:
// splice.mjs — put the region directly under the README's H1, and only there.
export function spliceUnderH1(readme, name, body, version) {
const region = [
`<!-- >>> brand:mast v${version} GENERATED, do not edit -->`,
body,
'<!-- <<< brand:mast -->',
].join('\n');
const existing = /<!-- >>> brand:mast [\s\S]*?<!-- <<< brand:mast -->/;
if (existing.test(readme)) return readme.replace(existing, region);
const anchor = new RegExp(`^# ${name}$`, 'm');
if (!anchor.test(readme)) {
throw new Error(`no anchor: the README's first heading must be exactly "# ${name}"`);
}
return readme.replace(anchor, (h1) => `${h1}\n\n${region}`);
}
Two behaviours are deliberate. Re-splicing replaces the existing region rather than adding another, so the operation is idempotent. And a README whose H1 is not exactly # <name> raises rather than guessing, because the alternative is worse than an error: a loose anchor such as ^# matches whatever heading comes first, and a missing anchor with a silent fallback appends a second banner below the first on the next run.
Use it, then verify it
// build.mjs — emit the banner, splice it, and save the result to render.
import { writeFileSync } from 'node:fs';
import { readmeMasthead } from './masthead.mjs';
import { spliceUnderH1 } from './splice.mjs';
const tokens = { identity: { stamp: 'Kit', name: 'Nate Swenson', byline: 'Nate Swenson' } };
const body = readmeMasthead(tokens, { brand_line: 'tally', document_kind: 'Claude Code skill' }, { version: '0.9.0' });
const readme = `# tally\n\n*Count things that matter, and nothing else.*\n\n## Why install this\n`;
const out = spliceUnderH1(readme, 'tally', body, '0.9.0');
writeFileSync('rendered.md', out);
console.log(out);
console.log('--- idempotent? ---');
console.log(spliceUnderH1(out, 'tally', body, '0.9.0') === out);
# tally
<!-- >>> brand:mast v0.9.0 GENERATED, do not edit -->
**Kit** · TALLY · CLAUDE CODE SKILL · KIT v0.9.0 · Nate Swenson
---
<!-- <<< brand:mast -->
*Count things that matter, and nothing else.*
## Why install this
--- idempotent? ---
true
Then render the result, because the whole point is that the source does not tell you:
gh api --method POST /markdown -f text="$(cat rendered.md)" -f mode=gfm
<h1>tally</h1>
<p><strong>Kit</strong> · TALLY · CLAUDE CODE SKILL · KIT v0.9.0 · Nate Swenson</p>
<hr>
<p><em>Count things that matter, and nothing else.</em></p>
<h2>Why install this</h2>
An <h1>, then a paragraph, then an <hr>. If that middle line comes back as <h2>, the blank line is missing.
Check the refusal path too:
node -e "
import('./splice.mjs').then(({spliceUnderH1}) => {
try { spliceUnderH1('# tally (beta)\n', 'tally', 'x', '0.9.0'); }
catch (e) { console.log('refused:', e.message); }
});
"
refused: no anchor: the README's first heading must be exactly "# tally"
What the medium takes away
Translating a visual component into markdown means giving things up, and it is worth writing down which, next to the code, so the next reader does not “fix” them:
- The banner sits under the H1, not above it. Every other medium opens on the banner. A repository page has to open on its own name, so here the H1 is the headline and the banner follows.
- The byline is not right-aligned. Markdown has no alignment short of raw HTML, and a table wrapper to fake it would put a box around the banner, which is a worse violation of the design than an unaligned byline.
- No monospace face. Backticks are markdown’s only monospace, and GitHub renders a code span as a filled rounded chip. Tracked capitals survive as capitals; the styling does not, so the stamp is plain bold.
Each of those lives in the emitter’s docstring, because a deviation with no recorded reason reads as an oversight.
Gotchas
The setext trap has no error and no warning. A missing blank line produces valid markdown that renders as a heading. Symptom: the banner is suddenly large and bold, and every automatic table of contents on the page grows an entry that is not a section. Escape: a test that renders and asserts on the HTML, not on the source, and a comment in the code saying the blank line is load-bearing.
A decorated H1 does not error, it detaches. If your anchor is ^# <name>$ and someone retitles the file, the next run finds no region and no anchor. Symptom: on the following run, a second banner appears lower down the file, or the region silently stops being updated. Escape: raise on a missing anchor, as above, and pin the H1 form in whatever check grades the file.
Enforce a generated house style outside the path filter. A README belonging to component A can be edited in component B’s pull request, and a path-filtered check will never look at it. Symptom: a shape that holds for months and then rots one pull request at a time. Escape: run the structural check unconditionally in a job that has no path filter.
Headings inside fenced code blocks are still headings to a naive scanner. Any check that counts ## lines to grade document structure will count the ones inside your examples. Symptom: a document whose last section looks like something else to the checker, or a false failure on a file that documents a markdown format. Escape: make the scanner fence-aware before you trust it.
A README over 500 KiB is truncated on GitHub. The docs state that “any content beyond 500 KiB will be truncated” when a README is viewed (about READMEs). That is a lot of banner, but generated content plus generated tables can approach it. Escape: keep generated regions small, and link out rather than inlining.
Sources
- CommonMark 0.31.2 specification — the setext heading rule, and the example showing the blank line producing a thematic break instead.
- GitHub Flavored Markdown spec — setext headings are inherited core CommonMark, not a GFM extension.
- About READMEs — where GitHub surfaces a README, and the 500 KiB truncation limit.
Changelog
- feat(press,smith): one README house style, generated by press and gated in CI (#152) (f2a0470)