Headings get an eyebrow, and the outline finally admits which ones are asides
Markdown gives you one line where editorial practice has always used three. `$$` and `&&` bind an eyebrow and a subheading to the heading they sit against — and the table-of-contents outline that shipped a release ago can now tell a real section from an `###` buried in a callout.
Headings get an eyebrow, and the outline finally admits which ones are asides
Why Care?
Every card component we’ve ever built renders a three-part heading: a small label above, the headline, and a line of supporting text below. It’s the standard editorial shape, older than the web — an eyebrow (newsprint called it a kicker), the head, and the deck.
Markdown gives you one of the three. ## and nothing else.
Long articles need the other two more than cards do. Nobody skims a card; a recipe with five job sections and a dozen sub-headings is skimmed by almost everyone who opens it, and a bare noun phrase tells a skimmer nothing about what kind of thing this section is or what it will tell them. An eyebrow does that in two words.
The second half of this release is less visible and more overdue. remarkLfmHeadingIds has been attaching a document outline since 0.4.0, described in its own source as “ordered outline, ready to render a table of contents.” Nobody rendered one, and when someone finally tried, the reason surfaced immediately: the outline collected the ### inside a > [!warning] callout exactly as if it were a document section. A ToC built from it offered readers waypoints that weren’t waypoints. That’s fixed, and deliberately not by dropping those headings — a share link into a callout is still a link, and it still has to land.
What’s New?
$$eyebrow and&&subheading syntax, bound by adjacency to the heading between them, rendering as one<hgroup>.^^is an accepted alias for$$from day one, so if display math ever lands we can deprecate$$for eyebrows without a content migration.inContaineron every outline entry — the name of the enclosingcallout,details,image-carousel,blockquoteorlistItem, innermost wins.eyebrowon the outline entry too, so a ToC can render the grouping label without re-walking the tree.nestHeadings()andfilterHeadings()— the two pure folds every ToC consumer was about to write privately.LfmHeadingNode, which the original proposal referenced but never actually defined.- Everything is additive. No anchor moves, no published fragment URL breaks.
$$ Portfolio Operations
## Every email from a portco, filed as PDFs
&& Two passes, inventory before export — so you can verify coverage
<hgroup class="heading-block">
<p class="heading-block-eyebrow eyebrow">Portfolio Operations</p>
<h2 id="every-email-from-a-portco-filed-as-pdfs">Every email from a portco, filed as PDFs</h2>
<p class="heading-block-subheading subheading">Two passes, inventory before export — so you can verify coverage</p>
</hgroup>
Position is the entire rule
The markers mean nothing on their own. $$ is an eyebrow only when the very next line is a heading; && is a subheading only when the previous line was one. Everywhere else they are ordinary text and pass through untouched.
Everything else falls out of that. No blank line between them, because a blank line breaks the binding. Order fixed, because there’s no marker-intrinsic meaning to reorder. The heading mandatory, because there’s no such thing as a floating eyebrow — without a heading there’s nothing to set context for. Either optional line omittable.
It’s also why this is safe to turn on by default: a parser that meets a $$ asks one question, and on virtually every document in the corpus the answer is no and it does nothing at all.
The implementation detail the spec didn’t anticipate: node order can’t answer that question. These two documents produce the same sibling pair in MDAST, because an ATX heading interrupts a paragraph either way.
$$ Ops $$ Ops
## Filing
## Filing
Only source positions tell them apart, so the binding test is heading.position.start.line === paragraph.position.end.line + 1. When either position is missing — a synthesized node, a tree built by hand — we refuse to bind rather than guess, because guessing wrong means silently swallowing someone’s prose.
The same reasoning made the marker bind a line rather than a block. Given Some prose\n$$ Ops\n## Filing, only the last line of that paragraph is adjacent to the heading, so only it becomes the eyebrow and the prose above stays put.
About that $$
$$ is the display-math delimiter in every LaTeX-flavored markdown on earth, and math is on the LFM wish list. Worth confronting rather than discovering later.
The positional rule defuses most of it. Display math opens with $$ and closes with $$, and the line after the opener is a formula — never an ##. The shapes barely overlap. What remains is a genuine but small residue: a document that opens a math block on the line directly above a heading. That case now binds as an eyebrow. It was malformed already; it now fails differently. We have a test asserting exactly that behavior, labelled as the known residue rather than as correct.
$ alone was rejected outright — “$2M ARR” and “$4.40 per million” are constant in the prose these sites publish, and a single-$ marker would misfire in every other paragraph. %% would have been worse than the math collision, since it silently swallows content in an Obsidian vault.
And the degradation matters more here than usual, because this syntax lands in files that get read outside our renderer. In Obsidian, on GitHub, in any plain preview, $$ Portfolio Operations renders as literal text on its own line above the heading. Ugly, not broken, still readable. Never choose a marker that makes the raw file unreadable.
The classes are the API
LFM ships no CSS, so the class names are the only thing a consumer can style against — which means they’re public API and get the same care as a function signature.
Each part carries two classes, and both do a job:
| Class | Why |
|---|---|
heading-block-eyebrow | Scoped to the block, so .heading-block .eyebrow outranks a site’s own .prose p. A bare class loses that specificity contest on every hand-rolled prose system. |
eyebrow | Deliberate reuse. Every card component in the house already styles this, the editorial role is identical, and inheriting the look by default is the right starting point. |
The eyebrow and subheading are <p>, never headings. That’s the accessibility guidance for hgroup — secondary content stays paragraph-level so it never enters the document outline — and it’s also what stops this feature from putting phantom sections in tree.data.headings. A subheading rendered as <h3> would show up in every table of contents as a section that doesn’t exist.
Honest caveat: hgroup maps to a generic role and is largely ignored in the accessibility tree, and no browser implements the HTML outline algorithm. It’s the semantically correct container and it buys less than it looks like it does.
A third prefix, because two weren’t enough
The previous entry said remark-* belongs to the remark ecosystem and LFM-specific plugins take lfm-*. Fine as a filename convention. Then we tried to apply it to a specific question — is the OG fetcher ours or remark’s? — and it fell apart twice before it worked.
First test: “does it contribute to remark’s standard library?” So we read the standard library. The remarkjs org maintains 34 plugins, and not one does callouts, citations, wikilinks, code-fence routing, OG fetching, link previews or heading ids. A test that returns the same answer for every input isn’t a test.
Second test: “did we write it?” Better — provenance is a fact, not a judgment, and it correctly protects remark-gfm and remark-directive as dependencies. But everything in src/plugins/ is ours, so it collapsed the same way, and it pushed heading-ids and code-fences to names that overclaimed how novel they are. Anchor ids on headings are not our idea.
What actually works is three tiers, because the capability and the behavior are different questions:
| Ask | Prefix |
|---|---|
| Is this handled by a plugin published by remark, or listed as a formal plugin? | remark-{name} |
| Do we substantially change how that plugin works and add our own flavor? | remark-lfm-{name} |
| Is this of our own making — unique syntax trigger, handled our way? | lfm-{name} |
Tier 2 is where most of a flavored-markdown package lives, and it’s the tier both earlier tests lacked. It says: the ecosystem knows this problem, our answer is different. The ecosystem word comes first because it’s the one a reader recognizes.
Assigning tiers meant reading remarkjs/remark/doc/plugins.md rather than reasoning from memory, and the list moved plugins in both directions. It has remark-wiki-link, remark-cite, remark-heading-id and several fence plugins — five of ours went from “ours” to tier 2 on that evidence. It has nothing for OG fetching, image carousels, or hgroup heading blocks — those three are tier 3 because the list is genuinely empty there.
Nothing landed at tier 1, which is what you’d expect: if a formal plugin already did the job well enough, we’d depend on it instead of writing one.
The rule now lives at context-v/blueprints/Naming-Plugins-Against-the-Remark-Ecosystem.md, including both discarded tests, so this doesn’t get re-litigated a third time.
| Was | Tier | Now | Export |
|---|---|---|---|
remark-callouts.ts | 2 | remark-lfm-callouts.ts | remarkLfmCallouts |
remark-citations.ts | 2 | remark-lfm-citations.ts | remarkLfmCitations |
remark-code-fences.ts | 2 | remark-lfm-code-fences.ts | remarkLfmCodeFences |
remark-heading-ids.ts | 2 | remark-lfm-heading-ids.ts | remarkLfmHeadingIds |
remark-lossless-wikilinks.ts | 2 | remark-lfm-wikilinks.ts | remarkLfmWikilinks |
remark-link-preview.ts | 3 | lfm-link-preview.ts | lfmLinkPreview |
og-fetcher.ts | 3 | lfm-og-fetcher.ts | lfmOgFetcher |
Every old export name still works, as a permanent-until-a-major alias — remarkCallouts === remarkLfmCallouts, asserted in the test run, because 20+ files across four sites import the old names. The lossless in remark-lossless-wikilinks also goes: it was the half-applied residue of a different naming proposal from May, and lfm already means Lossless Flavored Markdown.
Renaming the files cost nothing, which we checked rather than assumed: package.json exposes only ., ./types and ./formats, so no plugin file was ever publicly importable.
What it changed about the design
Splitting this release along that line was the right call before the rule was even settled, and it caught a bug.
lfm-heading-blocks owns every scrap of eyebrow knowledge, including back-stamping eyebrow onto outline entries the anchor plugin already built. The first draft had the anchor plugin stamping eyebrow directly — it would have worked, and it would have quietly given a generic plugin permanent knowledge of LFM syntax.
The bug that split avoided: the <hgroup> is a containerDirective. Had lfmHeadingBlocks run before remarkLfmHeadingIds, every eyebrow heading would have been stamped inContainer: "heading-block" — and any ToC filtering out container headings would have discarded the entire document. Running it after makes that structurally impossible. There’s still a TRANSPARENT_CONTAINERS guard for consumers who wire the two by hand in the other order, because “your ToC is empty and nothing errored” is a bad afternoon.
remarkGfm → remarkDirective → remarkLfmCallouts → remarkLfmCitations
→ remarkLfmHeadingIds → lfmHeadingBlocks → …
↑ order is load-bearing in both directions
One export we deliberately did not rename: remarkLfm, the preset. It names itself after the flavor exactly the way remarkGfm does, and it’s the single most-imported thing in the package. Flagging it as a conscious exception rather than an oversight.
What nestHeadings gets right that you’d get wrong
The outline is flat — depth carries the hierarchy, there is no children — so every ToC consumer writes the same flat-to-tree fold as its first act. It has enough edge cases that each would get them wrong independently, which is the whole argument for shipping it:
- a document that opens at
h3 - a jump from
h2straight toh4 - a trailing
h6 - depth returning to a shallower level after nesting
Depth gaps are not filled with placeholder nodes. An h4 under an h2 becomes a direct child, because inventing an empty h3 would put a waypoint in the ToC with no anchor to point at.
filterHeadings makes the other two judgement calls every consumer makes privately: an h2–h3 band by default, and synthetic entries dropped. A synthetic heading slugified to nothing, so it has no label worth showing — but its anchor is untouched and a share link to it still works. This is a helper and not a plugin option on purpose: trimming the outline at source would make it disagree with the anchors actually in the document.
Under the Hood
47 assertions, all passing, across the canonical block, the ^^ alias, partial blocks, blank-line rejection, bare-marker rejection, partial-paragraph takes, inline markup survival, inContainer for callouts and list items, the four nestHeadings edge cases, filterHeadings bands, the documented math residue, and headingBlocks: false.
Two behaviors worth naming because they’re easy to get wrong:
Inline markup survives the split. $$ **Bold** ops keeps its strong node in the rendered eyebrow while data.headingBlock.eyebrow flattens to "Bold ops" for consumers that want the string. Splitting a paragraph into lines cuts text nodes at \n and leaves every other inline node intact.
Consecutive && lines each become their own <p>, which hgroup permits — resolving one of the spec’s open questions in the direction it was leaning.
Files Touched
src/plugins/lfm-heading-blocks.ts new — $$ / ^^ / && binding, hgroup emission,
outline back-stamp
src/plugins/remark-lfm-heading-ids.ts
inContainer ancestry, nestHeadings, filterHeadings
src/plugins/remark-lfm-{callouts,citations,code-fences,wikilinks}.ts
src/plugins/lfm-{link-preview,og-fetcher}.ts
renamed by tier; each keeps its old export as an alias
src/types/index.ts LfmHeading += inContainer, eyebrow
LfmHeadingNode, HeadingBlockData, headingBlocks option
src/preset.ts register lfmHeadingBlocks after remarkLfmHeadingIds
src/index.ts exports, old + new
tsup.config.ts entry paths follow the renames
changelog/2026-08-17_01.md correction note on the plugin count and the rule
context-v/blueprints/Naming-Plugins-…
the rule itself, as a blueprint
package.json, deno.json 0.4.1 → 0.5.0
What’s Next?
This is a minor bump, and that has a consequence we learned the hard way in August. npm’s caret on a 0.x version pins the minor, so a site on ^0.4.0 will not pick this up without a manifest change. That’s the right outcome here — headingBlocks changes default parsing behavior, and a site should adopt that deliberately rather than on its next pnpm install.
Downstream, two things unblock:
astro-knots/context-v/specs/Reading-Position-Table-of-Contents-for-LFM-Articles.mdwas blocked oninContainerand no longer is.fullstack-vc/src/components/markdown/toc-types.tswas written to be deleted rather than migrated — same names, same signatures. It can go.
Also included in 0.5.0: the package’s first test suite — 124 assertions across every plugin, which found a nested-callout bug that had been shipping for months. See [[2026-08-17_03]].
Still open, and deliberately not decided here: whether inContainer should carry nesting depth alongside the innermost name, and whether repeated eyebrows across a document should let a ToC group by them. Both are additive if we want them later.
The three viewport states, scrollspy, collapse behavior, and measuring a site’s pinned header stay out of the package permanently. The seam holds: the package decides what a heading is called and where it sits; the render layer decides what the reader sees.
References
- [[Maintain-Eyebrow-Heading-Subheading-Blocks]] — the syntax spec
- [[Maintain-Table-of-Contents-from-the-Heading-Outline]] — the consumer contract
- [[Heading-Outline-Cannot-Distinguish-Container-Headings]] — the issue this closes
- [[Maintain-Heading-Anchors-and-Share-Links]] — why the
##keeps sole ownership of the anchor