0.5.0 — carousels, eyebrow headings, a table-of-contents contract that knows what an aside is, and the first tests
Three capabilities that were each specified separately and land together so you upgrade once: `:::image-carousel` normalized to one payload, `$$`/`&&` editorial heading blocks, and an outline that can finally tell a real section from an `###` buried in a callout. Plus 124 tests, which found a callout bug on their first run.
Why Care?
Three separate specs, three separate itches, one release — because landing them apart would mean three upgrade cycles for consumers who need any two of them.
Sequences of screenshots are one thing with an order, not several things that happen to be adjacent, and markdown has never had a way to say so. Long articles get skimmed, and a bare ## gives a skimmer a noun phrase with no indication of what kind of thing this section is. And the document outline that shipped in 0.4.0 — described in its own source as “ready to render a table of contents” — turned out not to be, the first time somebody tried.
Everything here is additive. No anchor moved, no published fragment URL broke, and every export name from 0.4.1 still resolves.
Image carousels
lfmImageCarousel normalizes :::image-carousel (and the :::img-carousel alias) into a single renderer-ready payload — slides extracted, alias collapsed, order resolved — so an Astro renderer, a Svelte renderer and a plain-HTML renderer read one contract instead of each re-walking children.
:::image-carousel{variant="stepper" title="Setting up Aside"}
::image{src="/Aside__Welcome_20260817T164659Z.jpg" alt="Welcome screen" label="Welcome"}
::image{src="/Aside__Recovery_20260817T164659Z.jpg" alt="Recovery key" label="Recovery key"}
:::
Slides may be ::image{} leaf directives or plain . Four variants (filmstrip, stepper, peek, contact-sheet), of which the first three are sequences and default to chronological ordering.
The ordering has a trap worth knowing about. Chronological sorting reads the ISO 8601 basic-format stamp the house image-prep convention appends to each filename — but that stamp is applied once per prep run, not once per image. It records when a batch was processed, not when each screen was captured. The sort is therefore stable, falling back to authored position, so the ordinary case (capture a sequence, prep it in one batch) resolves entirely to the order you wrote and is correct. Chronological only ever reorders across batches, which leaves exactly one failure mode: an image belonging mid-sequence that was uploaded later. sort="authored" opts out.
Eyebrow, heading, subheading
Every card component in the house already renders a three-part heading. Markdown gives you one of the three.
$$ 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</p>
</hgroup>
Position is the entire rule. The markers mean nothing on their own — they bind only by adjacency to a heading, and everything else follows: no blank line between, order fixed, the heading mandatory, either optional line omittable. A parser meeting a $$ asks one question, and on virtually every existing document the answer is no and it does nothing. That’s what makes it safe on by default.
The eyebrow and subheading are <p>, never headings — both the hgroup accessibility guidance and what keeps them from appearing in your table of contents as sections that don’t exist.
The class names are public API. LFM ships no CSS, so they’re the only thing you can style against. Each part carries two: heading-block-eyebrow is scoped to the block so .heading-block .eyebrow outranks a site’s own .prose p, and the bare eyebrow is deliberate reuse of what your card components already style.
On $$ and LaTeX. It’s the display-math delimiter everywhere, and math is on the wish list. The positional rule defuses most of the overlap — a math block’s next line is a formula, never an ## — leaving a small residue: a document that opens display math on the line directly above a heading. Malformed already; it now fails differently. ^^ ships as an accepted alias from day one, so if math ever lands, $$ can be deprecated for eyebrows without a content migration.
The table-of-contents contract
tree.data.headings has existed since 0.4.0. Nobody rendered one, and the first real attempt found out why: the outline collected the ### inside a > [!warning] callout exactly as though it were a document section, so a ToC built from it offered readers waypoints that weren’t waypoints.
Fixed — and deliberately not by dropping those headings. A share link into a callout is a link like any other and still has to land.
export interface LfmHeading {
id: string;
text: string;
depth: 1 | 2 | 3 | 4 | 5 | 6;
duplicateOf?: string;
synthetic?: boolean;
inContainer?: string; // innermost callout / details / blockquote / listItem
eyebrow?: string; // when the heading is part of an eyebrow block
}
export interface LfmHeadingNode extends LfmHeading { children: LfmHeadingNode[] }
export function nestHeadings(headings: LfmHeading[]): LfmHeadingNode[];
export function filterHeadings(h: LfmHeading[], min?: number, max?: number): LfmHeading[];
inContainer carries the container’s name, not a boolean — the same cost and strictly more useful, because details headings are genuinely navigable sections while callout headings are asides, and a boolean forecloses telling them apart.
The two helpers ship for the same reason slugifyHeading does: every consumer writes them, and the fold has edge cases (a document opening at h3, an h2 → h4 jump, a trailing h6) each consumer would get wrong independently. Depth gaps are not filled with placeholders — inventing an empty h3 would put a waypoint in the ToC with no anchor to point at.
nestHeadings(filterHeadings(outline, 2, 3).filter(h => !h.inContainer))
Out of scope permanently: components, layout, breakpoints, scroll tracking, and measuring your pinned header. The seam holds — the package decides what a heading is called and where it sits; the render layer decides what the reader sees.
Every plugin was renamed
Two naming rules were tried and discarded before one worked. Both failed the same way: they returned the same answer for every plugin in the package.
The rule that works asks about capability and behavior separately:
| 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} |
Assigning tiers meant reading remarkjs/remark/doc/plugins.md rather than reasoning from memory, and the list moved plugins in both directions.
| Export in 0.4.1 | Export in 0.5.0 | Tier |
|---|---|---|
remarkCallouts | remarkLfmCallouts | 2 |
remarkCitations | remarkLfmCitations | 2 |
remarkCodeFences | remarkLfmCodeFences | 2 |
remarkHeadingIds | remarkLfmHeadingIds | 2 |
remarkLosslessWikilinks | remarkLfmWikilinks | 2 |
remarkLinkPreview | lfmLinkPreview | 3 |
remarkOgFetcher | lfmOgFetcher | 3 |
Nothing breaks. Every 0.4.1 name remains a live alias, permanent until a major, and the identity is asserted in the test suite. remarkLfm — the preset — is unchanged; it names itself after the flavor exactly as remarkGfm does.
The full rule, including both discarded tests, is at context-v/blueprints/Naming-Plugins-Against-the-Remark-Ecosystem.md.
The first tests, and what they found
124 assertions across all nine plugins. node:test and node:assert, zero new dependencies, run against dist/ so each run exercises the built artifact rather than the TypeScript.
pnpm test # builds, then runs everything
pnpm test:only # skip the build when dist/ is current
They found a bug that had been shipping for months. remark-lfm-callouts swapped a transformed blockquote into place and then continued — skipping the recursion at the bottom of its loop. So it descended into every blockquote it didn’t transform, and never into one it did. A callout nested inside a callout was never visited, and its marker survived as literal text on the page:
> [!info] Outer rendered: Outer
> > [!warning] Inner [!warning] Inner Inner body.
> > Inner body. ↑ on the page, no error, no warning
One line, now guarded.
They also documented a limitation rather than asserting a wish. remark-lfm-citations defines an orphan-reference warning that can never fire: remark-gfm only creates a footnoteReference when a matching definition exists, so a typo’d [^typo] stays ordinary text and the plugin never sees it. The practical cost is that a mistyped footnote id renders visibly on the page with no build-time warning. The behavior is unchanged and the reasoning lives in the test.
The splash half-dogfoods the package now
0.4.1 listed this as a known gap:
The splash renders markdown through Astro’s built-in pipeline, not LFM — so the package’s own showcase doesn’t yet dogfood the package.
/demo closes half of it. Eight features, each showing authored markdown beside the payload parseMarkdown actually returned when the page was built. Nothing on it is hand-written illustration, which makes it an integration test: a regression shows on the page, and a plugin that throws fails the build.
It earned that on its first run — the rename had broken two splash pages that import plugin files by path, and the package’s tsc doesn’t cover splash/, so nothing had failed until Astro tried to build.
The other half is still open, and it shows. Every changelog and context-v page still renders through Astro’s built-in renderMarkdown (splash/src/content.config.ts), so none of the plugins run on the site’s own prose. The result is visible today: 13 pages leak raw [[wikilinks]] — including one in this release’s own changelog entry — and [!info] callout markers render as literal text on several others. That is exactly the failure remark-lfm-wikilinks exists to prevent, happening on the package’s own site. Fixing it means routing the loader through remarkLfm and giving the splash components for callouts, carousels and heading blocks — a real piece of work, not a config flag, and not in this release.
Upgrading
pnpm add jsr:@lossless-group/lfm@^0.5.0
This is a minor bump, and on a 0.x version npm’s caret pins the minor. A site on ^0.4.0 will not pick 0.5.0 up without a manifest change — and that’s the right outcome here, because headingBlocks changes default parsing behavior. Adopt it deliberately rather than on your next install.
Nothing breaks once you do:
- Every 0.4.1 export name still resolves.
- No anchor moved, so no published fragment URL changed.
inContainerandeyebroware new optional fields; code readingid/text/depthis unaffected.- Carousels and heading blocks are inert on documents that don’t use them.
The one behavior change to be aware of: $$ or ^^ or && on a line adjacent to a heading now means something. Pass headingBlocks: false to opt out entirely.
Known gaps
lfm-og-fetcherhas no tests. It’s the one plugin that makes network calls, and testing it properly wants a fixture backend rather than a live fetch.- Nothing runs
pnpm teston push. The suite exists; CI does not. Small and obvious next step. - No renderer consumes
data.carouselor the<hgroup>in an authored file yet. The/demopage proves the parsers via its own build-time call; the authoring path through a site’sAstroMarkdownstill needs components. inContainercarries only the innermost container’s name. Whether it should also carry nesting depth is open.- The filename stamp remains a run stamp. The durable fix belongs upstream in the image-prep script — stamp per-image capture time from EXIF or mtime — after which chronological ordering means what it says.
- The splash’s own content does not run through LFM.
/democallsparseMarkdowndirectly, but changelog and context-v pages still render through Astro, so wikilinks and callouts appear raw on 13 published pages. Carried forward from 0.4.1 and now measured rather than merely noted. - Kroki remains unevaluated, carried forward from 0.4.1.