← Corpus / astro-knots / spec
Codifying a Comprehensive Extended Markdown Flavor and Shared Package
A specification for a named, versioned extended markdown flavor that codifies our existing rendering capabilities, incorporates features from GFM, Obsidian, MDX, and directive-based systems, and defines a wish list for future development — all backed by a shared remark/rehype package.
- Path
- specs/Codifying-a-Comprehensive-Extended-Markdown-Flavor-and-Shared-Package.md
- Authors
- Michael Staton, AI Labs Team
- Augmented with
- Claude Code (Opus 4.6)
- Tags
- Markdown · Extended-Markdown · Remark · Rehype · Unified · Astro · Render-Pipeline · Content-Authoring
Codifying a Comprehensive Extended Markdown Flavor and Shared Package
Status: Draft (v0.0.1.1) Date: 2026-03-25 Author: Michael Staton
Workflow Status
New jot
- Backlinks
- Backlinks array resolver
- Backlinks conversion resolver
Responses and Feedback
Status: all five items addressed in §4.23.6.
- Map LinkPreviewData to Citation / Canonical Sources needs — addressed in §4.23.6 Shared data shape + new sub-section Field alignment with the canonical Sources schema + new sub-section Promote to Canonical (future flow, do not block on this). Strategy is minimum-viable bridge now, full canonical mapping later: aligned field names with the canonical schema’s vocabulary, added
canonicalSource?: { uuid, hexcode?, slug? }to LinkPreviewData so promotion is non-breaking, but explicitly deferred the full canonical pipeline (still being designed incite-wide). Cross-references thecite-wide/context-v/blueprints/Maximize-Data-Collection-on-Cannonical-Sources.mdblueprint. - Code-fence equivalents for Obsidian content — addressed in §4.23.6 Author syntax with a new subsection.
```link-preview/```link-rollup(lang = directive name) get converted to the same MDASTcontainerDirectivenode before Shiki’s syntax-highlighting pass. Registry-driven (only fences whose lang collides with a registered LFM directive are converted; reserved language names stay code blocks). Lives inremark-code-fence-as-directive(forthcoming). - OG fetcher — OpenGraph.io + future proxy — addressed in §4.23.6 Build-time OG fetch with a new “Fetch backend — pluggable” subsection. Backend is a config knob (
'direct' | 'opengraph-io' | 'proxy' | 'frontmatter-only'); OpenGraph.io is the recommended production default; proxy support is structurally provisioned as a sibling backend module so adopting it later is a one-module add, not a plugin-API break. - Aside positioning (
<aside>wrapping with float / escape-body attributes) — addressed in §4.23.6 with two new subsections: Aside positioning (theasideattribute) and the conflict-resolution paragraph at the end of it.asideis an enum (none | left | right | left-escape | right-escape);*-escapealigns the card outside the main content column in the same track where a docs-style TOC / file navigator / page-outline would live (not “page margin bleed”). Stable class contractlfm-aside lfm-aside--{value}. Compatibility matrix per component family. Width defaults +width=override. Cross-cutting: any future container directive (callouts, image directives, data-points, citation blocks) can adopt the same attribute with a one-line read ofnode.attributes.aside. Forward compatibility: future track-occupying components (TOC, file-nav, in-page outline) and escape-cards share the track; resolution policy (stack|inline-fallback|replace-on-section) is per-site config withstackas the default. Any forthcoming track occupant MUST publish adata-lfm-track-occupantattribute and render with the samelfm-aside--*-escapeclass so the resolver can disambiguate without a breaking change to theasideattribute. - No-code extensibility scheme (custom classes / attributes without AI agent help) — addressed in §4.23.6 with new subsection Author-extensible classes and attributes (no-code escape hatches). Three layers, in order of friction: (1) per-instance markdown attributes (
class,style,id,data-*,aria-*,lang) on any directive — always available, zero setup; (2) per-site variant registry atsrc/config/lfm-variants.yamlwithkind: <name>resolving to a class+attrs bundle — same schema across all astro-knots sites, optionally inheriting from a shared@lossless-group/lfm-variants-basepackage; (3) CSS custom-property tokens (--lfm-card-bg,--lfm-accent,--lfm-aside-track-width, etc., shipped inpackages/lfm-astro/css/tokens.css) for site-wide visual overrides with no markdown edits. Reserved attribute names (format,type,aside,kind, internaldata-lfm-*) are component-managed and filtered from author overrides. Validation policy is a config knob:strictAttributes: 'warn' | 'error' | 'silent'. Each component’s/design-systemcatalog page MUST list its managed attributes + the always-available escape hatches + links to the site variant registry and token file — that’s the contract a non-developer author reads to know what they can change.
Next
Basic to Advanced link support to external media
Design reference: §4.23.6 (Inline Link Substitutions) below codifies the component taxonomy and the OG-data contract. Read that section first before implementing — it spells out the directive grammar, file naming, type/format taxonomies, and the shared LinkPreviewData shape every component consumes.
Phase 0 — Foundation (must land before any component work):
- OG metadata fetcher (build-time, cached). Reuses the spec from §4.23.5 —
og:*+twitter:*extraction, JSON cache keyed by URL hash, configurable TTL, fail-cache for 4xx/timeout. Lives inpackages/lfm/src/plugins/og-fetcher.ts(forthcoming) and the cache lands in each consuming site atsrc/data/og-cache.json(gitignored). - Inline-link classifier — counterpart to the existing bare-link classifier. Reads the same
Bare-Link-Provider-Catalog.mdto determine if a URL maps to a known type (Article default, Video for known video providers, etc.). Emits a normalizedLinkPreviewDataobject the components consume. -
:::link-preview{type format}directive — wraps a single URL, opts into substitution rendering. See §4.23.6 for grammar. -
:::link-rollup{type format}directive — wraps multiple URLs into a single grouped component.
Phase 1 — LinkPreview__Article (first batch, simplest data shape):
-
LinkPreview__Article--Row.astro— horizontal row, one line tall, suited for inline-with-prose. Inputs: title, description, image, source domain. Acceptance: renders cleanly inside paragraphs, doesn’t break prose flow, works with one OG fetch. -
LinkPreview__Article--Card.astro— vertical card, suited for asides and feature blocks. Inputs: same as Row plus published date and reading time (when available). -
LinkPreview__Article--Thumb.astro— compact thumbnail with title only, for use inside rollups and dense lists. -
LinkPreview__Article--LiveSite.astro— sandboxed iframe of the actual page. Author opt-in only (:::link-preview{format=livesite trusted=true}); off by default for security.
Phase 2 — LinkPreview__Video (depends on bare-link catalog matchers): moved to its own spec — see [[Versatile-Component-Library-for-Video-Players]] at context-v/specs/Versatile-Component-Library-for-Video-Players.md. If the path has moved, search the repo: grep -r "Versatile Component Library for Video Players" . or fzf on the filename.
Phase 3 — LinkRollup containers (consume Phase 1+2 thumbs):
-
LinkRollup__Column.astro— vertical stacked list. Renders each child as aRowvariant matching its type. -
LinkRollup__Gallery.astro— grid layout (configurablecolumnsattr; default 3). Renders each child as aCardvariant. -
LinkRollup__Carousel.astro— horizontal scrolling with prev/next controls and pagination dots. Renders each child as aCardvariant. -
LinkRollup__ThumbRow--HorizontalScroll.astro— horizontal scroll ofThumbvariants, like an Apple Music album row. Densest layout; works well at the bottom of articles for “see also.”
Cross-cutting acceptance:
- All components consume the same
LinkPreviewDatainterface (defined in §4.23.6). - All components degrade to a plain autolink when OG fetch fails.
- All components are theme-token-neutral (read semantic tokens like
--card,--brand-aqua,--foreground,--borderwith hex fallbacks). Match the convention used inMermaidChartDisplay.astro,YouTubeEmbed.astro, etc. - Build-time OG fetches are batched (default
maxConcurrent: 10) with timeouts (default 5s), and failed fetches don’t block the build.
Table of Contents
- Table of Contents
- Side Navigation Left
- Table of Contents Right (Docs style)
- Table of Contents Preview Block / Inserted into Main above the content after the title / metadata.
- Table of Contents Mobile TopNav / Expand Left
Wikilinks / Backlinks
| Wikilinks | [[Page Name]] | Wish List, though working on https://lossless.group |
Done
- GitHub Flavor Markdown working
- Citations reordering, including for unique hexCode based citation pairs, is working.
- Support Mermaid through CDN (always updated)
- Add bare URL (auto-unfurl) support, conditional for video.
- component for bare url to video share links (support YouTube and Vimeo out of the box): LinkPreview__Video—FullPlayer.astro
In Review
- Image component with rich metadata including caption, source, and CSS styles. First iteration done. Looks good but haven’t thoroughly tested it.
- Citations vs sources?
1. Problem
We have a sprawling collection of markdown content across multiple sites and content repositories — investment memos, blueprints, specs, essays, changelogs, slide decks, infographics — and our rendering capabilities have grown organically through real client work. We now support features well beyond standard CommonMark or Remark: GFM tables, directive-based callouts, hex-code citations, Mermaid diagrams, embedded slide decks, markdown-based slideshows with slide separators, syntax-highlighted code blocks with copy buttons, and more.
But none of this is codified. The “flavor” is implicit — scattered across remark plugins, Astro components, and tribal knowledge. This means:
- No single reference for what syntax an author can use and expect to render
- No versioning of the flavor itself (which features are stable? which are experimental?)
- No shared package — each site copies and diverges its own remark/rehype pipeline
- No validation — authors discover unsupported syntax only when it renders wrong
- No wish list — features we want but haven’t built live only in people’s heads
2. Goal
Define a named, versioned extended markdown flavor (working name: Lossless Flavored Markdown or LFM) that:
- Codifies what we already support across our best rendering pipelines
- Borrows explicitly from GFM, Obsidian, MDX, and remark-directive conventions
- Defines tiers — Stable, Beta, and Wish List — so authors know what to rely on
- Backs the spec with a shared package (
@lossless-group/lfm) that sites can install or copy - Provides a validation mode that warns authors about unsupported syntax at build time
3. Prior Art and Borrowed Features
This flavor is a remix. We’re not inventing a new markdown standard — we’re selecting from existing ones and adding our own extensions where gaps exist.
That being said, our content preferences and standards are quite high. We want:
- To handle citations more rigorously than existing libraries support — with hex-code identifiers for stability, structured reference definitions with publication dates and URLs, hover popovers with source metadata, and build-time validation that every reference has a definition. We even want a source catalog and citation index, so readers can easily find and reference all sources… and authors can use more cannonical, valuable sources to assure the credibility and quality of our sources.
- To be able to specify CSS styles within the markdown itself — inline style overrides, scoped style blocks, and class annotations on any element — without dropping into raw HTML
- To embed content from YouTube, SoundCloud, and other media platforms with as little effort as possible — ideally a bare URL on its own line that auto-unfurls, or at most a one-line directive
- Custom components usually reserved for MDX to be automagically rendered using our directive syntax — no JSX, no imports, no build-step coupling — just
:::component-name{props}and the renderer maps it to the right Astro/Svelte component
3.1 CommonMark (Baseline)
Everything in the CommonMark spec is supported without modification. This is the floor.
3.2 GitHub Flavored Markdown (GFM)
We adopt the full GFM spec including:
| Feature | Syntax | Status |
|---|---|---|
| Tables | Pipe tables with alignment | Stable |
| Task lists | - [x] / - [ ] | Stable |
| Strikethrough | ~~text~~ | Stable |
| Autolinks | Bare URLs become links | Stable |
| Footnotes | [^label] with definitions | Stable (extended — see Citations) |
Implementation: remarkGfm plugin handles all of the above.
3.3 Obsidian / Wiki-Style Features
We selectively adopt from Obsidian’s markdown extensions:
| Feature | Syntax | Status |
|---|---|---|
| Wikilinks | [[Page Name]] | Wish List, though working on https://lossless.group |
| Wikilink with alias | [[Page Name|Display Text]] | Wish List |
| Embeds | ![[filename.md]] | Wish List |
| Image resize | ![[image.png|300]] | Wish List |
| Callouts | > [!info] Title | Beta (We support basical callouts and even have a few design mod components to support common tags like QUOTE, WARNING, etc. Yet these could be more full featured.) |
| Highlights | ==highlighted text== | Wish List |
| Comments | %%hidden comment%% | Wish List |
| Tags in body | #Tag-Name inline | Wish List |
Design Decision: We support Obsidian callout syntax (> [!type]) as an alias for our directive-based callouts. Authors coming from Obsidian can keep using > [!warning] and it renders identically to :::callout{type="warning"}. But our canonical documentation recommends the directive syntax because it’s more expressive (supports arbitrary attributes).
3.4 Core Principle: Polyglot Syntax, Unified Component Model
This is the most important architectural decision in LFM and what sets it apart from every existing markdown extension library.
The problem with existing libraries: Every library picks ONE syntax for triggering components and refuses the others. MDX requires JSX. Markdoc requires {% tag %}. remark-directive requires :::name{}. Obsidian uses code fences. Each library treats its chosen syntax as a philosophy rather than a preference — and forces that choice on every content author.
Our position: There is no good reason for this. A content author writing :::callout{type="warning"}, {% callout type="warning" %}, and ```callout { "type": "warning" } ``` is doing the exact same thing — asking the system to render a component with some properties. The syntax is just a trigger. The underlying operation is identical: resolve a component name, pass it properties, optionally pass it children, render it.
LFM therefore supports multiple trigger syntaxes that all normalize to the same intermediate representation before the rendering layer ever sees them. The rendering layer receives a component node with name, attributes, and children — it never knows or cares which syntax produced it.
Author writes ANY of these: The parser normalizes ALL of them to:
:::callout{type="warning"} ─┐
Content here. │
::: │
│ {
{% callout type="warning" %} ─┤ type: "componentNode",
Content here. │ name: "callout",
{% endcallout %} │ attributes: { type: "warning" },
├──▶ children: [ ...parsed markdown... ]
```callout ─┤ }
{ "type": "warning" } │
``` │
│
> [!warning] ─┤
> Content here. │
─┘
The canonical syntax for documentation is directives (:name, ::name, :::name) because they’re the most expressive and compose well with markdown. But we parse and accept ALL of these trigger syntaxes:
3.4.1 Supported Trigger Syntaxes
| Syntax Family | Trigger Pattern | Inline | Block | Container | Status |
|---|---|---|---|---|---|
| Directive (remark-directive) | :name[]{}, ::name{}, :::name{} ... ::: | Yes | Yes | Yes | Stable |
| Obsidian code fence | ```identifier with JSON/YAML content | No | Yes | No | Stable |
| Obsidian callout | > [!type] Title | No | No | Yes | Stable |
| Markdoc tag | {% name attrs %}...{% /name %} | No | Yes | Yes | Planned |
| MDX-lite | <Name prop="val" />, <Name>...</Name> | Yes | Yes | Yes | Wish List |
| Bare URL (auto-unfurl) | URL on its own line matching a known platform | No | Yes | No | Stable |
Each syntax family has its own remark plugin (parser), but ALL of them produce the same normalized node type that the rendering layer consumes. Adding a new trigger syntax is just adding a new parser — the rest of the pipeline is unchanged.
3.4.2 Why We Accept Markdoc Syntax
Markdoc ({% tag %}) deserves special attention because it’s used by Stripe’s documentation and has a growing community. The syntax is clean, well-specified, and solves the same problem we solve with directives:
{% callout type="warning" %}
Watch out for this.
{% /callout %}
{% youtube id="dQw4w9WgXcQ" /%}
Rejecting Markdoc syntax (as we originally planned) would mean an author who has existing Markdoc content — or who simply prefers the {% %} syntax — would have to rewrite everything. That’s exactly the kind of arbitrary enforcement we’re trying to avoid. The Markdoc parser is straightforward: scan for {% name ... %} delimiters, extract name and attributes, normalize to a component node.
3.4.3 Why We Accept MDX-Lite Syntax (With Restrictions)
Full MDX (JSX with arbitrary JavaScript expressions) is too complex and breaks content portability. But the component invocation part of MDX — <ComponentName prop="value">children</ComponentName> — is familiar to anyone who’s written HTML or React. We accept this syntax with restrictions:
- No imports — component routing is handled by the registry, not by authors
- No JS expressions — prop values must be string literals (
prop="value"), not expressions (prop={value + 1}) - No arbitrary JSX — only registered component names are recognized;
<div>or<span>are treated as raw HTML, not component invocations
This means an author can write <Callout type="warning">Content</Callout> and it works. It normalizes to the same node as :::callout{type="warning"}. The author doesn’t need to care which syntax they use.
3.4.4 Directive Syntax Detail (Canonical Form)
The generic directive proposal remains the canonical syntax for LFM documentation and examples:
| Form | Syntax | Use Case |
|---|---|---|
| Text directive | :name[content]{attrs} | Inline elements (badges, tooltips) |
| Leaf directive | ::name{attrs} | Self-closing blocks (embeds, separators) |
| Container directive | :::name{attrs}\ncontent\n::: | Wrapping blocks (callouts, galleries, details) |
Directives are the most expressive trigger syntax because they support all three forms (inline, block, container), allow arbitrary key-value attributes, and compose naturally with markdown. When we document a feature, we show the directive syntax first. But we never tell an author they must use it.
3.5 Prop Assignment and Component Routing
Regardless of which trigger syntax an author uses, the component model is the same:
| Concern | How It Works |
|---|---|
| Prop assignment | All trigger syntaxes produce string key-value attribute pairs (key="value"). No JS expressions — values are always strings. The component is responsible for parsing strings into the types it needs (e.g., parsing "3" into the number 3) |
| Component routing | A component registry maps names to component file paths at build time (e.g., 'callout' → Callout.astro). No imports in content. The registry is the single source of truth — one name, one component, regardless of which syntax triggered it |
| Children | Container syntaxes (directives, Markdoc tags, MDX-lite tags) pass their body as parsed markdown children. Code fence syntaxes pass their content as a raw string (typically JSON/YAML). The component must declare which input shapes it accepts |
| Frontmatter as props | YAML frontmatter can pass page-level props to layout components. This is standard Astro behavior and is orthogonal to the trigger syntax |
3.6 Other Technical Influences
| Source | Feature | Our Adoption | Status |
|---|---|---|---|
| AsciiDoc | Admonitions, includes | Admonitions via any trigger syntax; includes via embeds | Partial |
| Pandoc | Citation syntax [@key] | Extended — see hex-code citations below | Stable |
| Pandoc | Bracketed attributes {.class #id key="val"} | Adopted for CSS-in-markdown (see 4.30) | Wish List |
| reveal.js | --- slide separators | Adopted for slide content | Stable |
| Mermaid | Fenced code blocks and Diagrams as Text/Code | ```mermaid renders as diagrams | Stable |
| KaTeX/MathJax | $inline$ and $$block$$ | Math rendering | Wish List |
| Liquid/Nunjucks | {{ var }} / {% tag %} | Overlaps with Markdoc syntax — Markdoc parser handles this | Planned |
3.7 Philosophical Influences and Kindred Projects
LFM didn’t emerge in a vacuum. The mainstream markdown ecosystem (CommonMark, GFM, MDX) is maintained by large organizations with enterprise constraints — they move slowly, they prioritize backward compatibility over expressiveness, and they tend to treat “extended markdown” as someone else’s problem. The result is a gap between what content authors actually need and what the official specs provide.
That gap has been independently noticed by a number of solo developers and small teams who, like us, felt the need to improvise extended markdown because the existing options were insufficient. LFM is influenced by their work — not always by their specific syntax choices, but by the shared recognition that markdown needs to grow and that the people building the extensions shouldn’t be constrained by the conservatism of the spec committees.
WikiBonsai / CAML (Colon Attribute Markup Language)
Project: wikibonsai.io by a solo developer building a personal knowledge management system around semantic markdown.
What they built: CAML (:key::value syntax for inline metadata), wikirefs ([[links]] with semantic typing), a knowledge tree indexer, and a VS Code extension — all centered on the idea that markdown files should be nodes in a semantic graph, not isolated documents.
What resonated with us:
- The conviction that metadata shouldn’t be imprisoned in frontmatter. CAML allows attributes to live alongside content, like footnotes. This maps to our wish for inline annotations on claims (
:confidence::verified,:source-quality::high) rather than maintaining a separate metadata structure disconnected from the prose. - Wikiref values inside attributes (
:competitor::[[Enzymedica]]) — linking metadata to other documents, not just tagging with strings. - The Unix philosophy: modularity, plain text as the source of truth, readable by any tool. Their tagline — “readable by anyone, any model, any tool” — could be ours.
- The loneliness of the problem. When one solo developer independently arrives at the same conclusions you did, it validates the problem space even if the solutions diverge.
What we take: The idea of body-level metadata (not necessarily the :key::value syntax). The idea that wikilinks should carry semantic relationships, not just be navigation. The validation that extending markdown is a real need shared by real people, not scope creep.
Markdoc (Stripe)
Project: markdoc.dev by Stripe’s documentation team.
What they built: A full document authoring system with {% tag %} syntax for components, a schema validation layer, and a rendering pipeline that separates parsing from rendering — built because MDX was too complex for their content team.
What resonated with us:
- The explicit rejection of MDX as too developer-centric for content authors. They saw the same problem we did: JSX in markdown is powerful but hostile to non-developers.
- Schema validation for content — the idea that you can define what attributes a tag accepts and get build-time errors when content violates the schema. This directly influenced our directive registry and validation mode.
- The clean separation between parsing and rendering. Markdoc’s AST is syntax-agnostic, which is the same principle behind our polyglot normalizer.
Where they stopped short: Markdoc only supports {% tag %} syntax. They built a beautiful system and then locked it to a single trigger syntax, which is the exact trap we’re trying to avoid with the polyglot approach.
Djot
Project: djot.net by John MacFarlane (the creator of Pandoc and a CommonMark spec author).
What they built: A new light markup language designed to fix the accumulated warts in CommonMark — cleaner parsing rules, consistent attribute syntax, and better extensibility. MacFarlane was essentially admitting that markdown’s syntax has problems that can’t be fixed without breaking backward compatibility.
What resonated with us:
- The acknowledgment, from the person who wrote the CommonMark spec, that markdown has fundamental design issues that the spec process is too conservative to address.
- Djot’s attribute syntax (
{.class #id key="value"}on any element) directly influenced our CSS-in-markdown feature. Pandoc already supports a subset of this, and Djot formalizes it. - The idea that a markup language should have one consistent way to add attributes to any element, rather than the current markdown situation where attributes are possible on some elements, impossible on others, and vary by implementation.
What we take: Attribute syntax ideas for CSS-in-markdown. The confidence that even spec authors think markdown needs to evolve. We don’t adopt Djot as a syntax (it’s a wholly different language), but its design decisions inform our extensions.
Astro Content Collections / Zod Schemas
Project: Astro’s built-in content collections with Zod schema validation.
What resonated with us:
- Content as structured data with type-safe schemas, not just blobs of markdown. This gave us the frontmatter validation layer and the confidence that content can be programmatically reasoned about at build time.
- The
render()pattern that separates content body from metadata and gives the rendering layer full control. - This isn’t a “kindred solo developer” story — it’s an acknowledgment that Astro’s content model is the best available foundation for what we’re building. LFM extends it rather than replacing it.
The Broader Pattern
What all these influences share — and what motivated LFM — is the recognition that markdown is simultaneously the best and worst content format:
- Best because it’s plain text, universally readable, version-controllable, AI-friendly, and simple enough for anyone to learn in 10 minutes.
- Worst because the moment you need anything beyond headings and paragraphs — a callout, a citation, an embedded video, a data visualization, a styled component — you fall off a cliff into fragmented, incompatible extension ecosystems where every library has its own opinion about what you’re allowed to do.
LFM’s response is not to pick a side but to build a normalizer that accepts all sides and renders them the same way. The philosophical debt to these projects is in the shared conviction that the cliff doesn’t need to exist.
4. The Flavor: Feature Catalog
This is the canonical list of what Lossless Flavored Markdown or Astro Knots Markdown supports, organized by tier.
Tier 1: Stable (Ship It)
Features that are implemented, tested, and safe for authors to rely on across all sites.
4.1 Standard Markdown (CommonMark + GFM)
Everything you’d expect: headings, paragraphs, bold, italic, links, images, lists, blockquotes, horizontal rules, code spans, fenced code blocks, tables, task lists, strikethrough, autolinks, footnotes.
4.2 YAML Frontmatter
---
title: My Document
date_created: 2026-03-25
tags: [Extended-Markdown, Specifications]
authors:
- Michael Staton
---
Parsed by gray-matter or Astro’s built-in frontmatter handling. Schema validation is per-collection (Zod schemas in content.config.ts).
4.2.1 Tag Syntax
Due to our heavy reliance on Obsidian as our content development and management tool, tags cannot have spaces in them. And due to various search and filter features, it’s better to use Train-Case as opposed to snake-case.
4.3 Fenced Code Blocks with Syntax Highlighting
```typescript
const greeting: string = "Hello, world";
console.log(greeting);
```
Rendering: Shiki with tokyo-night theme (always dark). Wrapped in a container with:
- Language label (uppercase, top-left)
- Copy button (top-right)
- Line wrapping enabled
Meta string support (planned stable):
```typescript title="greeting.ts" {2-3} showLineNumbers
const greeting: string = "Hello, world";
console.log(greeting); // highlighted
return greeting; // highlighted
```
4.3.1 Custom Code Blocks Become Custom Components
Due to our use of Obsidian, Obsidian allows “plugins” to specify a codeblock identifier and give it custom rendering as a component. So, even though the ```identifier-string syntax is typically reserved for a true codeblock, there will be identifier strings that escape the codeblock render pipeline and trigger a custom component render pipeline instead.
Mermaid is the most familiar example of this pattern — ```mermaid is not a programming language, it’s a signal to bypass syntax highlighting entirely and render a diagram component. But we extend this pattern well beyond Mermaid to any component that Obsidian plugins know how to render.
Known custom code block identifiers (not syntax-highlighted — routed to components):
| Identifier | Renders As | Obsidian Plugin |
|---|---|---|
mermaid | Diagram (SVG) | Built-in Mermaid support |
jsoncanvas | Interactive node-and-edge canvas | Custom plugin |
card-carousel | Horizontal scrolling card carousel | Custom plugin |
card-grid | Responsive card grid layout | Custom plugin |
image-grid | Image gallery grid | Custom plugin |
image-carousel | Horizontal scrolling image gallery | Custom plugin |
slides | Embedded slide deck preview | Custom plugin |
Implementation: The remark pipeline maintains a component identifier list — code fence languages that should NOT be passed to Shiki for syntax highlighting. When the pipeline encounters one of these identifiers, the code block node is transformed into a directive-like node and routed to the component registry, just as if it were written as a directive. The fence content (typically JSON or YAML) becomes the component’s configuration data.
4.3.2 Custom Code Blocks May Also Be Directives Using the Same Custom Component
If we want a component to render INSIDE Obsidian, natively, it has to use custom identifiers within the codeblock syntax — that’s the only extension point Obsidian exposes for custom rendering. But our Astro sites prefer directive syntax (:::card-grid{columns="3"}) because it’s more expressive, supports nested markdown children, and participates in the directive validation system.
This means content authors will — frequently enough to not be an edge case — switch between using Code Block syntax and Directive syntax when they are referring to the same Custom Component. An author drafting in Obsidian uses the code fence because that’s what previews correctly. The same author (or a different one) editing in VS Code for an Astro site uses the directive because it’s the canonical LFM syntax. Therefore, in many cases both syntaxes must be supported and must route to the same component.
This has most frequently come up in Card Carousels, Card Grids, Image Grids, Image Carousels, and Slide Embeds.
Example — the same component, two syntaxes:
Code block syntax (works in Obsidian):
```card-grid
{
"columns": 3,
"cards": [
{ "title": "Enzymedica", "subtitle": "Primary Competitor", "url": "/competitors/enzymedica" },
{ "title": "FODZYME", "subtitle": "Direct Competitor", "url": "/competitors/fodzyme" },
{ "title": "Twin Health", "subtitle": "Indirect Competitor", "url": "/competitors/twin-health" }
]
}
```
Directive syntax (canonical LFM, works in Astro):
:::card-grid{columns="3"}
- **Enzymedica** — Primary Competitor [→](/competitors/enzymedica)
- **FODZYME** — Direct Competitor [→](/competitors/fodzyme)
- **Twin Health** — Indirect Competitor [→](/competitors/twin-health)
:::
Both render the same CardGrid.astro component. The code block version passes structured JSON as config; the directive version passes markdown children that the component parses. The component must handle both input shapes.
Implementation: The component registry supports dual registration:
const registry = {
'card-grid': {
component: () => import('../components/CardGrid.astro'),
codeBlockIdentifier: 'card-grid', // also triggers on ```card-grid
acceptsCodeBlockContent: true, // fence content passed as `data` prop
acceptsDirectiveChildren: true, // directive children passed as slot
},
};
4.4 Mermaid Diagrams
```mermaid
graph TD
A[Start] --> B{Decision}
B -->|Yes| C[Do Thing]
B -->|No| D[Other Thing]
```
Rendering: Extracted before Shiki processing via rehypeMermaidPre plugin. Rendered client-side by Mermaid.js with site-specific theme variables.
4.5 Callout / Admonition Blocks
Directive syntax (canonical):
:::callout{type="warning" title="Heads Up"}
This is important information that the reader should not miss.
:::
Obsidian syntax (alias, also supported):
> [!warning] Heads Up
> This is important information that the reader should not miss.
Both render identically. Supported types:
| Type | Icon | Color | Use |
|---|---|---|---|
info | i | Blue | General information |
tip | Lightbulb | Green | Helpful suggestions |
warning | Triangle | Yellow/Orange | Caution |
danger | X | Red | Critical warnings |
note | Pencil | Gray | Side notes |
success | Check | Green | Positive outcomes |
quote | Quote mark | Gray | Attributed quotes |
example | List | Purple | Examples/demos |
4.6 Inline Badges
This is a :badge[New] feature released :badge[2026-03-25]{variant="date"}.
Rendering: Styled <span> with variant-based colors. Variants: default, success, warning, danger, date, version.
4.7 Hex-Code Citations
Our custom citation system that extends standard footnote syntax:
Inline reference:
Global aging is accelerating toward 2.1B people 60+ by 2050.[^1ucdcd]
Definition:
[^1ucdcd]: 2025, Sep 21. [Population ageing](https://helpage.org/...). Published: 2024-07-11
Rendering:
- Inline: Superscript number
[1]with hover popover showing title, source, URL - Page bottom: Numbered “Sources” section with full references
- Hex codes converted to sequential integers in order of first appearance
See Citation-System-Architecture.md for the full design.
4.8 Tables (Enhanced)
Standard GFM pipe tables plus:
- Scroll wrapper for wide tables on mobile
- Sticky header option (via directive attribute)
- Sortable option (client-side JS, via directive attribute)
::table{scrollable sortable}
| Company | Funding | Stage |
|---------|---------|-------|
| Acme | $10M | Series A |
| Beta | $5M | Seed |
::
Note: The plain GFM table syntax always works. The directive wrapper adds progressive enhancement.
4.9 Slide Separators
For presentation content processed by the slides system:
---slide---
# Slide Title
Content for this slide.
---slide---
# Next Slide
More content.
The ---slide--- separator is only meaningful in content collections configured for slide rendering. In normal article rendering, it’s treated as a thematic break.
4.10 Details / Collapsible Sections
:::details{title="Click to expand"}
Hidden content that the reader can reveal.
Supports **full markdown** inside.
:::
Rendering: <details><summary> with styled disclosure triangle.
Tier 2: Beta (Works, Evolving)
Features that are implemented in at least one site but may change in syntax or behavior.
4.11 Image Directives
The ::image directive renders images as rich <figure> elements with optional captions, source attribution, and textbook-style float/wrap layouts. The design philosophy is write the minimum, get something good — most attributes are optional with sensible defaults, so authors only add attributes when something doesn’t look right.
Simplest usage (block figure, full width, caption below):
::image{src="/images/chart.png" alt="Market sizing" caption="GLP-1 projection through 2030"}
Floated with source attribution (image anchors right, caption auto-positions left, text wraps):
::image{src="/images/chart.png" alt="Market sizing" float="right" caption="GLP-1 projection" source="Goldman Sachs Research" source-url="https://gs.com/research/glp1"}
Full control (rarely needed — only when defaults don’t look right):
::image{src="/images/mobile-app.png" alt="Onboarding flow" float="left" width="50%" min-width="400px" max-height="500px" caption="Onboarding flow v2" caption-width="40%" source="Internal design team"}
Standard markdown images () also work but don’t support captions, floating, or source attribution.
Attribute Reference
| Attribute | Required | Type | Default |
|---|---|---|---|
src | yes | path or URL | — |
alt | yes | text | — |
float | no | left, right | none (block figure) |
width | no | percentage | 100% for block, 40% for floated |
min-width | no | pixels | component default breakpoint |
max-height | no | pixels | none |
caption | no | plain text | none |
caption-position | no | bottom, top, side | bottom for block; auto-side for floated |
caption-width | no | percentage of figure | 33% (applies only when caption is on the side) |
source | no | text | none |
source-url | no | URL | none (source renders as plain text) |
source-position | no | top, bottom | bottom; auto-flips to top if caption is also at bottom |
Sizing Model
widthis always a percentage of the content column, never pixels. The image scales proportionally.min-widthis in pixels and acts as a breakpoint: when the computed width of the figure falls below this value, floated images automatically unfloat and render as full-width block figures. If omitted, the component uses a sensible default breakpoint. This ensures images with fine detail or embedded text remain legible on smaller viewports.max-heightconstrains tall/narrow images (mobile screenshots, vertical infographics) from dominating the page. The image scales down to fit within the constraint while maintaining its aspect ratio.
Auto-Layout Rules
The image directive uses automatic layout logic so authors don’t need to think about positioning unless they want to override:
Caption positioning:
float="left"→ caption automatically goes to the right of the imagefloat="right"→ caption automatically goes to the left of the image- No float (block) → caption goes to the bottom (standard figure behavior)
- The
caption-positionattribute overrides any of these defaults when explicitly set.
Caption sizing:
- When caption is on the side, it takes 1/3 of the figure container by default (image takes 2/3). Set
caption-widthto override. - When caption is on top or bottom, it spans the full figure width.
- Caption font size uses
clamp(8pt, 2cqi, 14pt)— scales with the figure container, minimum 8pt, maximum 14pt. No author-facing attribute; baked into the component.
Source attribution positioning:
- Defaults to bottom of the figure.
- Automatically flips to top if caption is also at bottom, so they never compete for the same position.
- Renders as “Source: {text}” in small type. If
source-urlis provided, the source name becomes a link.
Responsive behavior:
- Floated images automatically unfloat to full-width block layout when the viewport is too narrow for the float to work (controlled by
min-widthor the component’s default breakpoint). - When an image unfloats, side captions move to bottom and the figure behaves like a standard block figure.
Layout Examples
Floated right with side caption (desktop):
Wrapping text continues here on the ┌─────────────────────────┐
left side of the content column, │ ┌───────────┬──────────┐│
flowing naturally around the figure. │ │ │ Caption ││
The figure anchors to the right edge │ │ [IMAGE] │ text on ││
of the content column. │ │ │ the left ││
│ ├───────────┴──────────┤│
│ │ Source: Goldman Sachs ││
│ └──────────────────────┘│
└─────────────────────────┘
Block figure with bottom caption (source flips to top):
┌──────────────────────────────────────────┐
│ Source: Goldman Sachs Research │
├──────────────────────────────────────────┤
│ │
│ [IMAGE] │
│ │
├──────────────────────────────────────────┤
│ GLP-1 market projection showing $130B │
│ addressable market by 2030. │
└──────────────────────────────────────────┘
Rendering
The ::image directive renders as a <figure> element containing:
- The
<img>withalt, lazy loading, and responsive sizing - A
<figcaption>for the caption (when present) - A source attribution element (when present)
- CSS layout using flexbox for side-caption arrangements and CSS
floatfor text wrapping - Container query-based font sizing for captions
4.12 Zero-Friction Media Embeds
Embedding media should require the absolute minimum effort. LFM supports three tiers of embed syntax, from effortless to precise — Tier A (bare URL auto-unfurl), Tier B (leaf directive), Tier C (generic embed fallback). All three route through the same provider classification.
Provider catalog (canonical record: packages/lfm/src/plugins/Bare-Link-Provider-Catalog.md — its frontmatter is the source of truth):
| Status | Platform / kind | URL shapes | Directive emitted | Component |
|---|---|---|---|---|
| ✅ Stable | YouTube (video / Short / Playlist) | youtu.be/{id}, youtube.com/watch, /shorts/{id}, /playlist?list={id} | ::youtube-* | see video spec |
| ✅ Stable | Vimeo | vimeo.com/{id} (incl. /{hash}, /channels/...), player.vimeo.com/video/{id} | ::vimeo | see video spec |
| 🟡 Planned | Loom | loom.com/share/{id} | ::loom | see video spec |
| 🟡 Planned | Spotify | open.spotify.com/{type}/{id} | ::spotify | SpotifyEmbed |
| 🟡 Planned | SoundCloud | soundcloud.com/{user}/{track} | ::soundcloud | SoundCloudEmbed |
| 🟡 Planned | Twitter / X | twitter.com/*/status/, x.com/*/status/ | ::tweet | TweetEmbed |
| 🟡 Planned | Figma | figma.com/file/, figma.com/design/ | ::figma | FigmaEmbed |
| 🟡 Planned | CodePen | codepen.io/ | ::codepen | CodePenEmbed |
| 🟡 Planned | GitHub Gist | gist.github.com/ | ::gist | GistEmbed |
Video — full component family lives in its own spec. The per-provider component contracts (sizing, aspect, accessibility, lazy-load/facade strategy), the inline-substitution variants (LinkPreview__Video--Row/Card/Thumb/LiveSite/FullPlayer), the multi-URL containers (LinkRollup__* × type="video"), the LinkPreviewData shape for video, and the S→T→C dispatch model are all specified in [[Versatile-Component-Library-for-Video-Players]] at context-v/specs/Versatile-Component-Library-for-Video-Players.md. If the path has moved, search the repo: grep -r "Versatile Component Library for Video Players" . or fzf on the filename.
Why a markdown file holds the catalog (not a TS file or JSON): the providers list is read by humans more often than by machines — when adding a new platform or auditing why a URL didn’t unfurl, plain-prose entries with named captures and explanatory description: fields are much faster to scan than typed code. The plugin’s build step extracts the frontmatter into a typed JSON module the runtime imports, so source-of-truth stays human-editable while runtime stays fast.
Tier B — Leaf directive (when you need control over embed behavior):
::soundcloud{url="https://soundcloud.com/user/track" color="#6643e2" visual}
::figma{url="https://www.figma.com/file/abc123" height="450"}
Tier-A and Tier-B share the same directive name per provider (the directive field in the catalog), so the renderer registers one dispatch arm per provider — not two. Directives give access to platform-specific attributes (start time, color, height, autoplay) that bare URLs don’t support.
Tier C — Generic embed (for unsupported platforms):
::embed{url="https://example.com/widget" height="400" title="Custom widget"}
Falls back to a sandboxed <iframe> with the URL. Use for platforms not in the auto-unfurl list.
Auto-unfurl opt-out: Prefix a URL with \ to prevent auto-unfurling and render it as a plain link.
Implementation (packages/lfm/src/plugins/remark-bare-link.ts): A remark plugin runs after remark-gfm’s autolink pass, walks paragraph nodes for the bare-URL signal (paragraph whose only child is a link whose value equals its url), matches against provider matchers in catalog order, and replaces matched paragraphs with a leafDirective node carrying { provider, id, url, kind } attributes. Inline link previews (the LinkPreview__*--Row/Card/Thumb family) are a separate transform that walks inline link nodes — the bare-link plugin never touches them.
Distinct from inline link previews: bare URL → embedded player (full media surface); inline URL with surrounding text → autolink today, optionally a LinkPreview__*--Row/Card/Thumb card later. Two transforms, two intents, one shared classification catalog.
4.13 Image Gallery
:::image-gallery{columns="3" gap="1rem"}




:::
Rendering: CSS Grid layout with configurable columns. Supports lightbox on click.
4.14 Table of Contents (Auto-Generated)
Not authored in markdown — generated by the remark pipeline from heading structure. Configurable:
- Minimum heading depth (default:
h2) - Maximum heading depth (default:
h4) - Render position: inline (for documents/PDFs) or sidebar (for web)
4.15 Backlinks
Bidirectional linking within a content collection:
See also [[Related Document Title]]
Rendering: Resolved at build time to actual URLs. Broken links flagged as warnings. Backlink lists generated per-document showing “pages that link here.”
Status: Implemented in the Lossless site (remark-backlinks.ts). Not yet ported to all Astro-Knots sites.
Tier 3: Wish List (Not Yet Implemented)
Features we want but haven’t built. Syntax is proposed, not final.
4.16 Math / LaTeX
The quadratic formula is $x = \frac{-b \pm \sqrt{b^2 - 4ac}}{2a}$.
$$
\int_0^\infty e^{-x^2} dx = \frac{\sqrt{\pi}}{2}
$$
Proposed implementation: remark-math + rehype-katex or rehype-mathjax.
4.17 Highlighted Text
This is ==critically important== to understand.
Rendering: <mark> tag with themed styling.
4.18 Obsidian-Style Embeds
![[other-document.md]]
![[other-document.md#specific-heading]]
Rendering: Transclusion — the referenced content is inlined at build time. Heading-specific embeds include only that section.
4.19 Wikilinks with Aliases
Read the [[Citation-System-Architecture|citation spec]] for details.
Rendering: Resolved to the correct URL at build time. Alias text used as link text.
4.20 Definition Lists
Term
: Definition of the term
Another Term
: Its definition
: A second definition
Rendering: <dl>, <dt>, <dd> elements.
4.21 Abbreviations
The HTML specification is maintained by the W3C.
*[HTML]: Hyper Text Markup Language
*[W3C]: World Wide Web Consortium
Rendering: <abbr> tags with tooltips on all occurrences of the abbreviated term.
4.22 Custom Containers for Content Types
:::investment-thesis
The core thesis is that enzyme-based metabolic interventions represent
a $50B+ market opportunity with regulatory advantages over GLP-1 drugs.
:::
:::key-risk{severity="high"}
Regulatory pathway is unproven for this specific enzyme combination.
:::
:::data-point{source="Goldman Sachs" date="2026-01"}
GLP-1 market projected to reach $100B by 2030.
:::
These map to domain-specific Astro components with specialized styling (e.g., investment thesis gets a distinctive border treatment; key risks get severity-colored indicators).
4.23 Smart Popovers and Link Previews
This is a family of hover/focus-activated “more info” surfaces that share a rendering system but are triggered by different content types. The unifying idea: any link or reference in your content can carry rich context that appears on hover without the reader leaving the page.
4.23.1 — OG-Enriched Link Previews (the flagship feature)
When a wikilink or standard link points to a page that has Open Graph metadata, hovering over that link shows a rich popover card with the OG image, title, description, and site name:
We evaluated [[Enzymedica]] as a primary competitor in the enzyme supplement space.
Our analysis builds on the [Goldman Sachs GLP-1 report](https://www.goldmansachs.com/insights/glp1-market-2030).
Rendering on hover over [[Enzymedica]]:
┌─────────────────────────────────────────┐
│ ┌─────────┐ │
│ │ [OG │ Enzymedica │
│ │ image] │ enzymedica.com │
│ │ │ │
│ └─────────┘ Leading enzyme supplement │
│ brand focusing on digestive │
│ health and metabolic support│
│ │
│ Tags: competitor · enzyme · supplement │
│ Last updated: 2026-03-20 │
└─────────────────────────────────────────┘
Rendering on hover over the Goldman Sachs link:
┌─────────────────────────────────────────┐
│ ┌─────────┐ │
│ │ [OG │ GLP-1 Market Outlook 2030 │
│ │ image] │ goldmansachs.com │
│ │ │ │
│ └─────────┘ Comprehensive analysis of │
│ the GLP-1 receptor agonist │
│ market trajectory... │
└─────────────────────────────────────────┘
Data sources for popover content (resolved in priority order):
| Link Type | Primary Data Source | Fallback |
|---|---|---|
| Wikilink to internal page | Frontmatter of the linked page (title, lede, image, tags) | Page’s first heading + first paragraph |
| Wikilink to content collection entry | Collection entry’s frontmatter | Entry body excerpt |
| External link | OG metadata fetched at build time (og:title, og:description, og:image) | URL domain + page title from <title> tag |
Citation reference [^hex] | Citation definition data (title, source, URL, date) | Already handled by citation popovers (4.7) |
Build-time OG fetching: External links are crawled at build time (with caching) to extract OG metadata. This data is serialized into a JSON manifest that ships with the page, so popovers are instant (no client-side fetch on hover):
// Generated at build time: /src/data/og-cache.json
{
"https://www.goldmansachs.com/insights/glp1-market-2030": {
"title": "GLP-1 Market Outlook 2030",
"description": "Comprehensive analysis of the GLP-1 receptor agonist market trajectory...",
"image": "https://www.goldmansachs.com/images/og-glp1-2030.jpg",
"siteName": "Goldman Sachs",
"fetchedAt": "2026-03-25T10:00:00Z"
}
}
Cache policy: OG data is cached per-URL with a configurable TTL (default: 7 days). Stale entries are re-fetched on the next build. Failed fetches (404, timeout, no OG tags) are cached as failures and retried after 24 hours.
4.23.2 — Wikilink Popover Cards
Internal wikilinks get especially rich popovers because we have full access to the linked page’s content:
The [[Citation-System-Architecture|citation system]] uses hex codes for stability.
Rendering on hover:
┌─────────────────────────────────────────┐
│ Citation System Architecture │
│ ───────────────────────────────────── │
│ A citation and reference management │
│ system for Astro sites that need to │
│ display research-backed infographics, │
│ data visualizations, and content with │
│ inline citations. │
│ │
│ Status: Stable · Updated: 2025-12-17 │
│ Tags: citations, references, markdown │
│ → │
└─────────────────────────────────────────┘
The popover content comes from the linked page’s frontmatter:
title→ popover headinglede(or first paragraph) → descriptionstatus,date_modified→ metadata linetags→ tag pills- Arrow icon → click-through to the full page
4.23.3 — Inline Tooltips (Author-Defined)
For terms that need explanation but don’t have a linked page, authors define the content inline:
The company uses :tooltip[CRISPR]{content="Clustered Regularly Interspaced Short Palindromic Repeats — a gene editing technology that allows precise modification of DNA sequences."} for its core platform.
Their :tooltip[LTV:CAC ratio]{content="Lifetime Value to Customer Acquisition Cost. A healthy SaaS business targets 3:1 or higher. Metabologic projects 8.3:1 at scale."} is projected at 8.3:1.
Rendering: Dotted underline on the term. On hover, a compact popover with the content text. Visually distinct from link popovers (no image, no metadata — just the explanation).
4.23.4 — Citation Popovers (Already Stable)
Citation markers [^hexcode] already render popovers via the global popover pattern described in Citation-System-Architecture.md. These share the same rendering infrastructure as the other popover types.
4.23.5 — Popover Rendering Architecture (Shared)
All four popover types use a single global popover system (the same pattern proven in the citation system):
┌─────────────────────────────────────────────────┐
│ Single global popover element at <body> level │
│ (escapes all overflow:hidden containers) │
└─────────────────────┬───────────────────────────┘
│
Event delegation on document
captures hover/focus on:
│
┌─────────┬───────┼────────┬──────────────┐
│ │ │ │ │
.cite- .wiki- .ext- .tooltip- .og-link-
marker link link trigger preview
│ │ │ │ │
▼ ▼ ▼ ▼ ▼
Citation Page OG card Inline OG card
popover card popover tooltip popover
Data flow:
- Citation popovers: Data stored in
data-citation-*attributes on the<sup>element - Wikilink popovers: Data stored in
data-preview-*attributes on the<a>element, populated from the linked page’s frontmatter at build time - External link OG popovers: Data stored in
data-og-*attributes on the<a>element, populated from the OG cache at build time - Inline tooltips: Data stored in
data-tooltip-contentattribute on the<span>element
All popover types use the same positioning logic (getBoundingClientRect + viewport boundary detection), the same show/hide animation, and the same keyboard accessibility pattern (tabindex="0", aria-describedby).
Popover opt-out: Not every link needs a popover. Authors can suppress it:
A plain link with no popover: [Goldman Sachs](https://gs.com){.no-preview}
A wikilink with no popover: [[Enzymedica]]{.no-preview}
Configuration (per-site):
remarkLfm({
popovers: {
wikilinks: true, // Popover cards for internal wikilinks
externalLinks: true, // OG popovers for external links
citations: true, // Citation popovers (default: always on)
tooltips: true, // Inline tooltip directives
ogFetch: {
enabled: true, // Fetch OG data at build time
ttl: 7 * 24 * 60 * 60, // Cache TTL in seconds (7 days)
timeout: 5000, // Per-URL fetch timeout (ms)
maxConcurrent: 10, // Concurrent fetches during build
userAgent: 'LFM-OGBot/1.0',
},
},
});
Print behavior: All popovers are hidden in @media print. For tooltips, the content is rendered inline in parentheses. For link previews, the URL is shown after the link text. Citations print their superscript number only (the Sources section at the bottom has the full references).
4.23.6 — Inline Link Substitutions (LinkPreview__* and LinkRollup__*)
The popovers in 4.23.1–4 keep the link as inline text and reveal a card on hover. Substitutions instead replace the link in the document flow with a visual preview component. Same OG cache (4.23.5) feeds both — different render strategy.
This section defines a unified link-grammar component family that gives authors three rendering positions for any URL:
| Where the URL appears | What renders | Example component |
|---|---|---|
| Bare on its own line (paragraph with single autolink child) | Full embedded media — see §4.12 | LinkPreview__Video--FullPlayer.astro |
| Inline in prose, default behavior | Plain autolink (CommonMark) | — none — |
Inline + author opt-in via :::link-preview directive | Substitution preview card | LinkPreview__Article--Row.astro, etc. |
Multiple URLs grouped in a :::link-rollup directive | Grouped collection | LinkRollup__Gallery.astro, etc. |
| Inline link, hover-only enrichment | Popover, link stays inline (4.23.1) | — uses 4.23.5 popover infra — |
Component naming taxonomy
Two families:
LinkPreview__{Type}--{Format}.astro— single-URL substitution. The__{Type}segment names the resource kind (drives data shape and data source); the--{Format}segment names the visual layout/density.LinkRollup__{Format}.astro— multi-URL container. No__{Type}segment because the type is carried by the directive’s args and applied uniformly to all children. Format segment names the layout primitive.
Both segments are CamelCase. The double-dash before {Format} is intentional: it survives Astro’s filename slug rules and visually separates the format axis from any sub-variant (e.g. ThumbRow--HorizontalScroll vs hypothetical ThumbRow--VerticalScroll).
Type taxonomy (LinkPreview__{Type})
| Type | Resource kind | Primary data source | Notes |
|---|---|---|---|
Article | Blog post, news article, long-form web page | OG metadata via build-time fetch | Default for any URL not matching a richer type |
Video | YouTube / Vimeo / Loom etc. | Provider matchers from Bare-Link-Provider-Catalog.md + OG fallback | Reuses bare-link classifier infrastructure. Full component family in [[Versatile-Component-Library-for-Video-Players]] (context-v/specs/Versatile-Component-Library-for-Video-Players.md; if moved, grep -r "Versatile Component Library for Video Players" .). |
Audio (planned) | Spotify / SoundCloud / podcast episode | Provider matchers + oEmbed | Tier 3 |
Code (planned) | Gist, CodePen, JSFiddle | Provider matchers + content fetch | Tier 3 |
Tweet (planned) | Twitter / X status | oEmbed | Tier 3 |
A new type lands in three steps: add a row to the bare-link catalog (or extend LinkPreviewData if the metadata shape differs), add the matcher to the inline-link classifier, ship the component variants.
Format taxonomy (--{Format})
For LinkPreview__* (single-URL):
| Format | Visual density | Best for | Min data |
|---|---|---|---|
Row | Low (one line tall, ~80px) | Inline in prose without breaking flow | title, source domain |
Card | Medium (~200-300px tall) | Asides, feature blocks, sidebars | title, description, image |
Thumb | High (small image + title) | Inside rollups, dense grids | title, image |
LiveSite | Variable (iframe of the page) | Author-trusted demos, interactive embeds | URL only (sandboxed) |
FullPlayer | Full-bleed embedded media | Bare-URL auto-unfurl path (§4.12) | type-specific (provider, id) |
For LinkRollup__* (multi-URL):
| Format | Layout | Children render as |
|---|---|---|
Column | Vertical stacked list | Row of the matching type |
Gallery | Grid (configurable columns) | Card of the matching type |
Carousel | Horizontal scroll with prev/next controls | Card of the matching type |
ThumbRow--HorizontalScroll | Horizontal scroll of compact thumbnails | Thumb of the matching type |
Author syntax (directive-based)
:::link-preview{type="article" format="card"}
https://example.com/some-article
:::
:::link-preview{type="article" format="livesite" trusted}
https://my-trusted-demo.example.com
:::
:::link-rollup{type="video" format="gallery" columns=3}
https://youtu.be/aaa
https://youtu.be/bbb
https://youtu.be/ccc
https://youtu.be/ddd
:::
Defaults:
typedefaults toArticlewhen omitted.formatdefaults toCardforLinkPreview,GalleryforLinkRollup.- Inside
:::link-rollup, thetypeapplies to every child URL. Mixed-type rollups require multiple containers.
The directive emits a single MDAST containerDirective node whose name is link-preview or link-rollup, with attributes captured into node.attributes. The renderer dispatches on name + attributes.format to the right component.
Obsidian-equivalent code-fence form (mandatory for content portability). Authors who edit in Obsidian have lots of content using community plugins that emit code fences with custom lang names (the safe-rendering pattern Obsidian uses for user-defined components). LFM MUST recognise a code fence whose lang matches a known directive name as semantically equivalent to the directive form:
```link-preview type="article" format="card"
https://example.com/some-article
```
```link-rollup type="video" format="gallery" columns=3
https://youtu.be/aaa
https://youtu.be/bbb
https://youtu.be/ccc
```
The fence info-string after the lang name carries the same key=value attributes the directive form accepts. The transform recognising this shape lives in remark-code-fence-as-directive (forthcoming) and runs before Shiki’s syntax-highlighting pass — fences claimed by the directive registry are converted to containerDirective nodes; everything else continues as a normal code block. The directive registry is the source of truth (the bare-link catalog already declares directive names; this work extends that registry to include link-preview, link-rollup, and any other LFM directives a site may add). Reserved words like js, ts, python, mermaid etc. stay code blocks — only fences whose lang collides with a registered LFM directive are converted.
This means the same content authored in Obsidian renders identically in our Astro pipeline without the author rewriting fences to ::: directives.
Aside positioning (the aside attribute)
Card, Thumb, and most LinkRollup__* formats accept an optional aside attribute that wraps the rendered component in a semantic <aside> element and positions it relative to the article column. Same affordance NYTimes-blog or Stripe-docs margin notes use — driven by markdown, not hand-rolled HTML.
aside value | Rendering | Wrapper | Use when |
|---|---|---|---|
none (default) | Inline document flow | regular block | The card is the focus of the paragraph; reading order matters. |
left | Floats inside the article column on the left; prose wraps right | <aside class="lfm-aside lfm-aside--left"> | The card is supplementary; you want it visually adjacent but the prose carries the argument. |
right | Floats inside the article column on the right | <aside class="lfm-aside lfm-aside--right"> | Same, visually opposite. |
left-escape | Aligns outside the main column in the left side track — where a docs-style file navigator / table-of-contents / page-nav typically lives | <aside class="lfm-aside lfm-aside--left-escape"> | The card belongs to the page’s navigational frame, not its prose flow — e.g. “while you’re reading this section, here’s the related piece you should keep handy.” |
right-escape | Aligns outside the main column in the right side track — where a docs-style “On This Page” TOC typically lives | <aside class="lfm-aside lfm-aside--right-escape"> | Same intent, opposite side. The most common production use (“see also” rail beside long-form prose). |
The *-escape values are explicitly not “page margin bleed” in the NYTimes-blog sense — they target the same horizontal track where a future LFM-managed TOC or file-navigator component would render. The card sits as a peer to that navigation, not as a margin embellishment. This matters for layout authoring: a site that knows it will eventually want a TOC reserves the escape tracks via its grid declaration; sites that want pure prose with optional escape cards reserve the same tracks but only render content into them when an aside=*-escape directive appears.
Author syntax (directive form):
Body paragraph that the floated card sits alongside. The reader's eye reads
this prose as the primary thread; the card is supplementary.
:::link-preview{type=article format=card aside=right}
https://example.com/related-piece
:::
Continuing prose that wraps around the floated card.
Code-fence form (Obsidian portability) accepts the same attribute:
```link-preview type=article format=card aside=right-escape
https://example.com/related-piece
```
Responsive fallback contract. The *-escape variants require a containing layout that exposes margin tracks (typically a CSS Grid declaring [full-bleed-start] 1fr [content-start] 65ch [content-end] 1fr [full-bleed-end] or equivalent). At narrow viewports — or when no margin track is available — escape variants MUST gracefully collapse to inline document flow. The component renders the same <aside> element either way; CSS handles the responsive switch via container queries on the layout shell. This is non-negotiable: a margin-bleed card that overflows the viewport on mobile is a layout regression.
Width defaults (sites override via theme tokens):
aside value | Default width |
|---|---|
left / right (float-in-column) | min(30ch, 50%) |
left-escape / right-escape (margin-bleed) | available margin track width, capped at 28ch |
Width override. When a non-default width is needed, pass a width attribute (any CSS length unit):
:::link-preview{type=article format=card aside=right width=20ch}
:::
Most useful with left/right floats; the *-escape variants are constrained by the layout’s margin track and the override is treated as a max-width hint.
Compatibility matrix:
| Component family | aside=none | left / right | left-escape / right-escape | Notes |
|---|---|---|---|---|
LinkPreview__*--Row | ✅ | ❌ | ❌ | Row variants flow with prose; aside conflicts. Build warning, falls back to inline. |
LinkPreview__*--Card | ✅ | ✅ | ✅ | Primary intended consumer. |
LinkPreview__*--Thumb | ✅ | ✅ | ⚠️ | Float-only; escape values fall back to float (thumb is too narrow to read at margin width). |
LinkPreview__*--LiveSite | ✅ | ⚠️ | ⚠️ | Allowed but discouraged — iframes inside floats produce reflow churn. |
LinkPreview__Video--FullPlayer | ✅ | ❌ | ❌ | FullPlayer owns full-bleed bare-URL layout. aside silently ignored. |
LinkRollup__Column | ✅ | ✅ | ✅ | Vertical lists are the canonical content for margin tracks. |
LinkRollup__Gallery | ✅ | ✅ | ⚠️ | Multi-column galleries inside a margin track collapse to 1-column. |
LinkRollup__Carousel | ✅ | ⚠️ | ❌ | Carousel controls assume full content width; build warning if floated. |
LinkRollup__ThumbRow--HorizontalScroll | ✅ | ❌ | ❌ | Designed for full-width horizontal scrolling. Build warning. |
Legend: ✅ supported · ⚠️ allowed but produces a build warning, may produce a fallback render · ❌ silently ignored or warned-and-fall-back.
Cross-cutting design intent. aside is intentionally generic. Future adopters: callouts (:::callout{type=info aside=right}), image directives (:::image{src=... aside=left-escape}), data-point blocks, citation blocks, etc. For this work scope only LinkPreview__* and LinkRollup__* honor it. Adopting it elsewhere is a one-line addition per component (read node.attributes.aside, wrap output in <aside class="lfm-aside lfm-aside--{value}"> when present, otherwise use a regular block).
The lfm-aside and lfm-aside--{value} class names are stable and become part of the LFM render-time contract — site stylesheets target them to wire up float / escape behavior. A reference stylesheet ships in packages/lfm-astro/css/aside.css (forthcoming) for sites to import; sites with custom layouts override per-token via the same selectors.
Conflict resolution with future track-occupying components (TOC, file navigator, etc.). The *-escape tracks are shared real estate — a future LFM-managed TOC component, a file-navigator, an in-page outline, or any other <aside>-class component might want to render in the same track. When a page combines them, the renderer must choose how to resolve the conflict. Three options, in order of preference:
- Stack (default) — both components share the track; track-occupying chrome (TOC, navigator) renders first, escape-cards stack below. The track scrolls if total height exceeds viewport. This is the docs-site mental model and the right default for almost every layout.
- Inline-fallback — escape-cards collapse to inline document flow whenever a track-occupying component is present, regardless of viewport width. Useful when the TOC is essential and the cards are nice-to-have.
- Replace-on-section — escape-cards take over the track only for the section they’re authored in; the TOC re-appears for sibling sections. Requires intersection-observer scroll coordination. Niche, document for completeness.
Resolution policy is configured per-site:
remarkLfm({
asideResolution: {
leftEscape: 'stack', // 'stack' | 'inline-fallback' | 'replace-on-section'
rightEscape: 'stack',
},
});
Forward compatibility contract. Any future LFM-managed component that wants to occupy a *-escape track MUST:
- Render with the
lfm-aside lfm-aside--{value}-escapeclass set (so site stylesheets target one selector for both content and chrome). - Publish a
data-lfm-track-occupantattribute (toc,file-nav,outline, etc.) so the resolver can disambiguate during thereplace-on-sectionmode. - Honor the
asideResolutionconfig and degrade gracefully when stacked beneath an escape-card.
Components that don’t yet exist but are foreseen (per the Workflow Status above): LinkRollup__Column deployed as a docs-side “related-pages” navigator, an in-page TableOfContents--SideRight, TableOfContents--SideLeft. When those land they slot into the same conflict-resolution model — no breaking change to the aside attribute is anticipated.
Author-extensible classes and attributes (no-code escape hatches)
Authors must be able to extend any LFM component — adding custom CSS classes, inline styles, data-attributes, or named site-specific variants — without touching TypeScript, forking the component, or asking an AI agent to modify code. The escape hatches must be uniform across all astro-knots sites, so the same syntax behaves identically wherever LFM renders.
Three layers, in order of friction:
Layer 1 — Per-instance attributes (always available, zero setup).
Every LFM container directive accepts these reserved keys, merged onto the rendered wrapper element. Component-managed attributes (e.g., format, aside, width) take precedence and cannot be overridden via Layer 1.
| Reserved attribute | Effect |
|---|---|
class="..." | Space-separated list of CSS classes appended to the component’s defaults. |
style="..." | Inline CSS, applied to the wrapper element. |
id="..." | DOM id (must be unique per page; the renderer warns on collisions). |
data-* | Any data-* key passes through to the wrapper. Use for analytics tracking, data-binding hooks, etc. |
aria-* | Any aria-* key passes through to the wrapper. Component defaults remain unless author explicitly overrides. |
lang="..." | Language hint for the wrapped content (overrides inherited document language). |
:::link-preview{type=article format=card class="brand-glow my-feature-card" data-track="opp-pitch-q2" aria-label="Featured analysis"}
https://example.com/article
:::
renders as (conceptually):
<aside class="lfm-link-preview lfm-link-preview--card brand-glow my-feature-card"
data-track="opp-pitch-q2"
aria-label="Featured analysis">
<!-- card contents -->
</aside>
The component’s built-in class set (lfm-link-preview lfm-link-preview--card) is always present and always comes first in the class list, so site stylesheets can rely on cascade order.
Layer 2 — Per-site variant registry (named bundles).
Authors who repeat the same class set across many components define a named variant in their site’s variant registry, then reference it with a single kind attribute. This is the path for site-wide design language extensions.
The registry lives at src/config/lfm-variants.yaml (canonical filename, picked up automatically by the LFM preset). Schema:
# src/config/lfm-variants.yaml
# Per-site LFM variant registry. Each top-level key matches a directive name.
# Each "kind" inside that key declares a bundle of classes and attributes
# the directive expands when the author writes `kind=<name>`.
link-preview:
kind:
primary:
class: "border-2 border-aqua bg-card-elevated"
attrs:
data-emphasis: "high"
muted:
class: "opacity-75 grayscale-50"
investor-badge:
class: "ring-2 ring-purple-500 ring-offset-2"
attrs:
data-audience: "investor"
link-rollup:
kind:
sources-rail:
class: "border-l-2 border-aqua pl-4"
attrs:
data-rollup-purpose: "sources"
callout:
kind:
confidential:
class: "bg-amber-50 border-amber-400 ring-1 ring-amber-300"
attrs:
data-classification: "confidential"
Authors then write:
:::link-preview{type=article format=card kind=investor-badge}
https://example.com/diligence-piece
:::
The renderer expands kind=investor-badge against the registry: appends ring-2 ring-purple-500 ring-offset-2 to the class list, adds data-audience="investor" to the wrapper. Layer-1 attributes on the same directive (class="...", data-*=...) merge on top of the variant’s bundle (author-instance class wins for explicit collisions).
Why YAML, not TypeScript: the registry should be human-editable by anyone on the team (designers, content authors, project leads) without requiring a build step or type-check pass. A typo in YAML produces a build warning at next render; a typo in TypeScript breaks the build. Consistent with feedback_yaml_data_files — YAML for client-editable data, TypeScript for runtime logic.
Cross-site portability: every astro-knots site uses the same registry filename, the same schema. A variant bundle that works on mpstaton-site works identically on cilantro-site if both sites declare the same kind. Sites can also import a shared baseline registry from a @lossless-group/lfm-variants-base package (forthcoming) and override per-site.
Layer 3 — Theme tokens (the design-system escape hatch).
For visual customization that’s universal across components — wrapper background, border radius, accent color, etc. — the components read CSS custom properties. Sites override the tokens in their global stylesheet; no markdown change required.
Reserved tokens (a complete list ships in packages/lfm-astro/css/tokens.css):
:root {
/* Wrapper surface */
--lfm-card-bg: var(--card, #ffffff);
--lfm-card-fg: var(--foreground, #1A0D33);
--lfm-card-border: var(--border, rgba(145, 56, 224, 0.15));
--lfm-card-radius: 8px;
/* Accent (used by hover, focus, badges) */
--lfm-accent: var(--brand-aqua, #04E5E5);
--lfm-accent-hover: var(--brand-aqua, #04E5E5);
/* Aside-specific */
--lfm-aside-track-width: 28ch; /* width of escape tracks */
--lfm-aside-float-width: min(30ch, 50%);
--lfm-aside-gap: 2rem; /* gap between aside and prose */
/* Caption / metadata */
--lfm-caption-bg: var(--card, rgba(255, 255, 255, 0.05));
--lfm-caption-fg: var(--foreground, #e2e8f0);
}
Sites override one or more in their tokens.css and every LFM component picks up the change with no markdown edits, no variant-registry edits, no JS reload. Token names are stable across LFM versions; renames go through deprecation cycles.
Reserved attribute names (cannot be overridden by authors):
These are managed by the component and silently filtered if an author tries to set them via Layer 1:
format— selects the variant file (Row/Card/Thumbetc.)type— selects the data shape (article/video/ etc.)aside— positioning (see prior subsection)kind— variant registry key (Layer 2)- Any internal
data-lfm-*attributes the component sets for layout-resolver coordination
Setting these via Layer 1 is silently ignored (with a build warning in dev mode).
Validation policy. The LFM preset accepts a strictAttributes flag:
remarkLfm({
strictAttributes: 'warn', // 'warn' (default) | 'error' | 'silent'
});
warn— unknown attributes pass through to the wrapper as-is and the build emits a warning. Default.error— unknown attributes fail the build. Use in CI / production to catch typos.silent— pass-through with no warning. Use during heavy authoring sessions to avoid noise.
class, style, id, data-*, aria-*, lang are always-known and never trigger validation. The reserved component attributes and any registered kind values are also always-known. Everything else is “unknown” by definition.
Documentation discoverability. Each component’s reference page (in the design-system catalog at /design-system/...) MUST list:
- Its component-managed attributes (with allowed values).
- The Layer-1 escape hatches that always work.
- A link to the site’s variant registry showing the configured
kindvalues. - A link to the token list (
packages/lfm-astro/css/tokens.css).
This is the contract that lets a non-developer author find what they can change without reading source.
Shared data shape
LinkPreviewData is the contract every LinkPreview__* and LinkRollup__* component reads from. It is intentionally a minimum-viable subset of the canonical Source schema described in cite-wide/context-v/blueprints/Maximize-Data-Collection-on-Cannonical-Sources.md — field names align with the canonical schema’s vocabulary so that promoting a preview into the canonical catalog is a non-breaking enrichment, not a rename.
// packages/lfm/src/types/index.ts (forthcoming addition)
export interface LinkPreviewData {
// === Identity ===
url: string; // canonical href; aligns with `accessed_at_url` in the canonical schema
type: 'article' | 'video' | 'audio' | 'code' | 'tweet' | 'unknown';
// === Surface metadata (extracted at build time from OG/twitter tags) ===
title?: string; // aligns with `title`
description?: string; // aligns with `lede` (subtitle is canonical-only for now)
image?: string; // aligns with `piece_og_image`
imageAlt?: string; // local-only; canonical schema doesn't require it
source?: string; // aligns with `publisher` (display name when available, host fallback)
sourceUrl?: string; // aligns with `publisher_url`
publishedAt?: string; // aligns with `date_published` — ISO date
updatedAt?: string; // aligns with `date_modified` — ISO date if available
duration?: string; // ISO 8601 duration for video/audio (no canonical equivalent yet)
authors?: string[]; // aligns with `authors` — array, matches canonical shape
// === Provider-specific (matches the bare-link catalog) ===
providerId?: string; // e.g. YouTube video ID, Vimeo numeric ID
providerExtra?: Record<string, string>; // e.g. { hash: 'abc123' } for unlisted Vimeo
// === Bridge to canonical Sources catalog (set iff promoted) ===
canonicalSource?: {
uuid: string; // `internal_uuid` from canonical schema
hexcode?: string; // `reference_hexcode` — already used in our citation system
slug?: string; // `default_slug` — for routing to the canonical entry's page
};
// === Provenance ===
fetchedAt?: string; // when OG was last successfully fetched
cacheStatus: 'hit' | 'miss' | 'failed';
}
Every LinkPreview__* and LinkRollup__* component takes a data: LinkPreviewData (or data: LinkPreviewData[] for rollups) prop. Components MUST render a graceful fallback when cacheStatus === 'failed' or required fields are missing — typically a plain link or a skeletal card with just URL + domain.
Field alignment with the canonical Sources schema
The canonical schema lives in cite-wide and has 17+ fields covering the full archival lifecycle (download paths, API references, AI-classified tags, etc.). LinkPreviewData covers the render-surface subset that’s available from OG metadata alone. Naming alignment so that future canonicalization is additive, not breaking:
| LinkPreviewData field | Canonical Sources field | Notes |
|---|---|---|
url | accessed_at_url | Same semantic — the URL we fetched from |
title | title | Direct map |
description | lede | Canonical also has subtitle for content types that use both |
image | piece_og_image | Canonical also has piece_thumbnail_url for non-OG thumbnails |
source | publisher | Display name (canonical AI-normalizes “nytimes.com” → “The New York Times”) |
sourceUrl | publisher_url | Direct map |
publishedAt | date_published | Direct map (ISO 8601) |
updatedAt | date_modified | Direct map (ISO 8601) |
authors | authors | Direct map (array) |
canonicalSource.uuid | internal_uuid | Stable interop anchor |
canonicalSource.hexcode | reference_hexcode | Already used by our citation system |
canonicalSource.slug | default_slug | For routing to the canonical entry’s markdown page |
| (not in preview) | publisher_favicon_url | Canonical-only — too noisy for build-time fetch |
| (not in preview) | publisher_type | Canonical-only — AI classification, not deterministic |
| (not in preview) | tags | Canonical-only — AI-derived |
| (not in preview) | edition_or_version | Canonical-only — applies to a subset of source types |
| (not in preview) | api_provider_url, api_provider_name, api_source_url | Canonical-only — requires search |
| (not in preview) | downloaded_content_path, structured_data_path | Canonical-only — bit-preservation paths |
| (not in preview) | cited_in_files | Canonical-only — backlink index |
| (not in preview) | date_accessed, date_added | Canonical-only — archival timestamps; preview’s fetchedAt is the closest analogue |
When a URL is promoted to the canonical catalog, LinkPreviewData on existing pages remains valid; the renderer additionally sets canonicalSource on subsequent fetches so components can decorate previews with a “curated source” indicator and link to the canonical entry’s slug.
Promote to Canonical (future flow, do not block on this)
The cite-wide blueprint describes a “Promote to Canonical Source” button that triggers an agent pipeline (UUID generation, download, structured-data extraction, AI classification, etc.). When that lands:
- The user picks a URL from any LFM-rendered page (preview, popover, citation marker — all share the OG cache).
- The agent fetches and enriches into a full canonical Source record (markdown file with overkill frontmatter in the canonical store).
- The OG cache entry for that URL is updated to set
canonicalSource: { uuid, hexcode, slug }. - On the next site build, every
LinkPreview__*component rendering that URL automatically picks up the canonical pointer and can render a “curated” badge.
This is a one-way bridge by design: previews can become canonical; canonical entries don’t downgrade. Implementation of this flow is out of scope for the link-preview component work — they are separate tracks that share the OG cache as their integration surface. Do not block component shipping on the canonical pipeline.
See: cite-wide/context-v/blueprints/Maximize-Data-Collection-on-Cannonical-Sources.md for the canonical schema design, the AI vs. deterministic field-fill strategy, and the second-brain rationale (“the rich schema is only applied to a few sources per piece of our content… 1-5 that I want to promote to a canonical archive”).
Rendering pipeline
Markdown source
│
▼
remark-parse + remark-gfm + remark-directive // standard LFM preset
│
▼
remark-bare-link (forthcoming) // bare URLs → leafDirective (§4.12)
│
▼
remark-link-preview (forthcoming) // :::link-preview / :::link-rollup
│ walks containerDirective nodes
│ classifies child URL(s) via shared matchers + OG cache
│ attaches LinkPreviewData to node.data
│
▼
AstroMarkdown.astro // dispatches on directive name + attributes.format
│
├── name="link-preview" attr.format="row" → <LinkPreview__Article--Row data={...} />
├── name="link-preview" attr.format="card" → <LinkPreview__Article--Card data={...} />
├── name="link-rollup" attr.format="gallery" → <LinkRollup__Gallery items={...} />
└── ... etc.
Build-time OG fetch
Single fetcher serves both substitutions and hover popovers. Implementation lives in the LFM package as a remark transform (packages/lfm/src/plugins/og-fetcher.ts — forthcoming) plus a TypeScript helper (packages/lfm/src/utils/og-cache.ts). Per-site cache lands at src/data/og-cache.json and SHOULD be gitignored — sites regenerate on demand and cache hits are 100% on subsequent builds.
Configuration is declared once on the LFM preset (see 4.23.5 example). Both popovers.ogFetch and (forthcoming) linkPreviews.ogFetch read from the same options block; if a site enables either feature, the cache populates for all observed URLs.
Fetch backend — pluggable, with a managed-service default. Many publisher pages defeat naive fetch() (Cloudflare challenges, bot blocks, lazy-rendered OG meta, paywall walls). The fetcher MUST support pluggable backends so a site can pick what trades off against:
| Backend | When to use | Notes |
|---|---|---|
Direct fetch() | Simple sites, dev / smoke testing | Fails on ~10-20% of real-world URLs (Cloudflare, JS-rendered pages, anti-scrape headers). Free. |
| OpenGraph.io (default for production) | Production builds across all astro-knots sites | API-driven, renders JS, normalises OG/twitter/Schema.org into one shape. The maintainer has an active account; a single API key environment variable powers all sites. Recommended default. |
| Managed scraping proxy (future) | Pages OpenGraph.io can’t reach (private intranet, auth-walled archives, IP-banned hosts) | Maintainer flagged this as something they’d subscribe to when needed; not blocking. |
| Author-supplied frontmatter | Highly curated content — manual override | Skips fetch entirely; the author hands LFM the title/description/image. Useful inside the canonical Sources catalog flow. |
Backend selection is a per-site config knob:
remarkLfm({
popovers: {
ogFetch: {
enabled: true,
backend: 'opengraph-io', // 'direct' | 'opengraph-io' | 'proxy' | 'frontmatter-only'
apiKey: process.env.OPENGRAPH_IO_KEY,
ttl: 7 * 24 * 60 * 60,
timeout: 5000,
maxConcurrent: 10,
// proxy-specific options when backend === 'proxy'
proxyUrl: process.env.SCRAPER_PROXY_URL,
},
},
});
The direct backend stays available for offline dev and as a free fallback. The plugin code SHOULD be a small dispatcher over backend modules — each backend exports the same async function (url: string, opts) => Promise<OGFetchResult>. Adding a new backend (e.g., a self-hosted Browserless instance later) is a single new module + a config-string entry, no plugin-API churn.
Why substitutions are a separate primitive from popovers
Both share the OG cache and the LinkPreviewData shape. The render strategy differs:
- Popovers (4.23.1): link stays inline; on hover, the global popover system (4.23.5) shows a card. Use when the author wants the URL to read like a normal citation/reference and only some readers want richer context.
- Substitutions (4.23.6): link is replaced by the card in the document flow. Use when the author wants the URL to be the rendered artifact (a curated “see this video” moment, an opinionated rollup of references, a feature card for a related read).
Authors who want both behaviors on the same URL use a popover by default and opt into substitution per-instance via the directive.
Print behavior
Same as popovers (4.23 above): substitution components print as a clean text block — title, source, URL on its own line. The interactive iframe in LinkPreview__*--LiveSite collapses to a notice + URL.
4.24 Timeline / Changelog Blocks
:::timeline
- **2024 Q1**: Founded, initial research
- **2024 Q3**: Pre-seed funding ($500K)
- **2025 Q1**: First clinical results
- **2025 Q4**: Series A ($5M target)
:::
Rendering: Vertical timeline with date markers and styled entries.
4.25 Multi-Column Layout
::::columns{count="2"}
:::column
Left column content with full markdown support.
:::
:::column
Right column content.
:::
::::
Rendering: CSS Grid or Flexbox layout. Collapses to single column on mobile.
4.26 Tabs
::::tabs
:::tab{label="JavaScript"}
```js
console.log("hello");
:::
:::tab{label=“Python”}
print("hello")
::: ::::
**Rendering**: Tabbed interface with client-side switching. No page reload.
#### 4.27 Steps / Numbered Procedures
```markdown
:::steps
### Install dependencies
```bash
pnpm add @lossless-group/lfm
Configure your pipeline
Add the plugin to your unified pipeline.
Write content
Start using extended syntax. :::
**Rendering**: Numbered step indicators with connecting lines. Each `###` heading becomes a step.
#### 4.28 Aside / Sidenote
```markdown
Main paragraph content continues here.:sidenote[This is a marginal note that appears in the margin on wide screens and inline on narrow screens.]
Rendering: Tufte-style sidenotes on desktop, inline expandable on mobile.
4.29 JSON Canvas Visualization
```jsoncanvas
{
"nodes": [...],
"edges": [...]
}
```
Rendering: Interactive node-and-edge canvas rendered client-side. Already prototyped in remark-jsoncanvas-codeblocks.ts.
4.30 Dialog / Chat UI
Render a back-and-forth conversation between two (or more) participants as a chat interface. This is particularly relevant for documenting AI-assisted workflows, user interviews, design critiques, or any content where the exchange between speakers IS the content.
Directive syntax:
:::dialog{participants="Michael=human, Claude=ai"}
Michael: So I've been thinking about the citation system. What if we used hex codes instead of sequential numbers?
Claude: That solves the portability problem — a citation keeps its identifier regardless of where it appears. You could copy a paragraph between documents without renumbering.
Michael: Exactly. And it makes grep actually useful across the content corpus.
Claude: The only cost is readability in raw markdown — `[^a1b2c3]` is less meaningful than `[^1]` to a human scanning the source. But that's a worthwhile tradeoff given the stability benefits.
Michael: Agreed. Let's do it.
:::
Rendering: A chat-style UI with:
- Participant avatars or initials on alternating sides (human left, AI right — or configurable)
- Message bubbles with distinct styling per participant role
- Participant names above or inside each bubble
- Timestamps (optional, if provided)
- Smooth vertical flow, mobile-friendly
Participant roles and styling:
| Role | Default Alignment | Default Style |
|---|---|---|
human | Left | Solid bubble, primary color |
ai | Right | Outlined bubble, accent color |
system | Center | Muted, full-width, no bubble |
user | Left | Alias for human |
assistant | Right | Alias for ai |
Extended syntax with metadata per message:
:::dialog{participants="Michael=human, Claude=ai" theme="dark"}
Michael [2026-03-25 14:30]: First message with timestamp.
Claude [2026-03-25 14:31]: Response with timestamp.
> system: Claude is now using the extended thinking model.
Michael: A message without a timestamp is fine too.
:::
Code fence syntax (for Obsidian compatibility):
```dialog
participants: Michael=human, Claude=ai
---
Michael: So I've been thinking about the citation system.
Claude: That solves the portability problem.
Michael: Exactly.
```
The code fence version uses a YAML header (above ---) for config and the rest as the conversation body. Both syntaxes render the same Dialog.astro component.
Multi-participant support:
:::dialog{participants="Michael=human, Sarah=human, Claude=ai"}
Michael: What do you both think about the trigger map approach?
Sarah: I love it. Way simpler than the plugin assembly.
Claude: Agreed — and the YAML config makes it accessible to non-developers.
Michael: Ship it.
:::
More than two participants get distinct colors auto-assigned from a palette, or authors can specify colors:
:::dialog{participants="Michael=human:#9138E0, Sarah=human:#22A6B5, Claude=ai:#F59C49"}
Print behavior: Chat bubbles flatten to a simple transcript format — participant name in bold, followed by their message. No bubbles, no alignment, just readable prose.
Why this matters: A huge amount of valuable content in our workflow IS the conversation — the back-and-forth with AI where decisions get made, alternatives get explored, and reasoning gets documented. Right now that content either gets lost (context window clears) or gets pasted as raw text with no visual structure. A first-class dialog component makes AI collaboration a publishable content type.
4.31 Obsidian Bases (.base Files)
Obsidian introduced Bases — a YAML-based file format (.base) that defines database-like views over your vault’s frontmatter properties. Think of it as a lightweight Notion/Airtable that lives as a plain-text file and queries your existing markdown files by their frontmatter.
A .base file defines filters, formulas, and views — and Obsidian renders it as a sortable, filterable table UI. We want to support rendering .base files (or the equivalent syntax embedded in a code fence) as interactive data tables on our sites.
```base
filters:
and:
- "category = Specification"
- "status != Archived"
formulas:
age: "dateDiff(now(), prop('date_created'), 'days')"
views:
- type: table
columns: [title, category, status, age, authors]
sort: { property: date_modified, direction: desc }
**Rendering**: A sortable, filterable data table populated from the site's content collection frontmatter at build time. Essentially Obsidian Dataview/Bases for Astro — query your content, render the results as a table.
**Status**: Wish List. The `.base` YAML schema is straightforward; the interesting work is connecting the filter/formula engine to Astro content collections at build time.
#### 4.32 CSS-in-Markdown
One of our strongest differentiators from other markdown flavors: the ability to specify CSS directly in content without dropping into raw HTML. Three levels of control:
**Level 1 — Class annotations on any block** (via directive attributes):
```markdown
:::callout{type="info" .hero-callout .gradient-border}
This callout gets custom CSS classes applied to its wrapper element.
:::
## Section Heading {.accent-underline}
A paragraph with a specific class. {.lead-text}
The {.classname} syntax (borrowed from Pandoc/kramdown) applies CSS classes to the preceding block element. Multiple classes are space-separated.
Level 2 — Inline style overrides (via style attribute on directives):
::image{src="/hero.jpg" style="border-radius: 1rem; box-shadow: 0 4px 20px rgba(0,0,0,0.3)"}
:::callout{type="info" style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white;"}
A callout with a custom gradient background.
:::
The style attribute is available on ALL directives and passes through to the rendered element’s inline styles. This gives authors escape-hatch control without raw HTML.
Level 3 — Scoped style blocks (for complex per-document styling):
```css scoped
.lead-text {
font-size: 1.25rem;
line-height: 1.8;
color: var(--color-muted-foreground);
}
.hero-callout {
border-image: linear-gradient(135deg, #667eea, #764ba2) 1;
border-width: 2px;
}
.accent-underline {
text-decoration: underline;
text-decoration-color: var(--color-accent);
text-underline-offset: 0.3em;
}
```
A fenced code block with language css and the scoped meta flag is NOT rendered as a code block. Instead, its contents are injected as a <style> tag scoped to the current document’s container. This gives authors full CSS power for per-document customization without affecting other pages.
Security: The style attribute and scoped CSS blocks are sanitized at build time — url(), expression(), javascript:, and @import are stripped. This prevents content injection while allowing legitimate styling.
Implementation:
- Level 1: A remark plugin that parses
{.class}annotations on blocks (similar toremark-attror Pandoc’s bracketed attributes) - Level 2: The directive-to-component mapping passes
styleattributes through to rendered elements - Level 3: A rehype plugin that detects
```css scopedblocks, extracts the CSS, removes the node from content flow, and injects it as a scoped<style>tag in the rendered output
4.31 Auto-Component Rendering (MDX Without MDX)
The most ambitious feature in our flavor: any directive name that matches a registered component is automatically rendered as that component — no imports, no JSX, no .mdx file extension required. This gives us the power of MDX with the portability of plain .md files.
How it works — the author’s perspective:
:::pricing-table{tiers="3" highlight="pro"}
:::
::team-grid{layout="cards" department="engineering"}
:::feature-comparison{products="metabologic,enzymedica,fodzyme"}
| Feature | Metabologic | Enzymedica | FODZYME |
|---------|------------|------------|---------|
| Enzyme types | Proprietary blend | Generic | Digestive only |
| Clinical data | 3 peer-reviewed | None | 1 pilot study |
| Price/month | $60-120 | $30-50 | $40-60 |
:::
The author writes a directive. The rendering layer looks up the directive name in the component registry and renders the corresponding Astro or Svelte component, passing directive attributes as props and directive children as slot content.
How it works — the system’s perspective:
Author writes: :::pricing-table{tiers="3" highlight="pro"}
remarkDirective → containerDirective node
parses it as: name: "pricing-table"
attributes: { tiers: "3", highlight: "pro" }
AstroMarkdown.astro → Looks up "pricing-table" in component registry
renders it as: → Finds: PricingTable.astro
→ Renders: <PricingTable tiers="3" highlight="pro" />
Component registration (per-site):
// src/config/markdown-components.ts
export const markdownComponents = {
'pricing-table': () => import('../components/PricingTable.astro'),
'team-grid': () => import('../components/TeamGrid.astro'),
'feature-comparison': () => import('../components/FeatureComparison.astro'),
'logo-grid': () => import('../components/LogoGrid.astro'),
'metric-card': () => import('../components/MetricCard.astro'),
'before-after': () => import('../components/BeforeAfter.astro'),
// ... any component the site wants to expose to markdown authors
};
Why this is better than MDX:
| Concern | MDX | Our Approach |
|---|---|---|
| File extension | Must be .mdx | Standard .md — no tooling changes |
| Imports | Author writes import X from '../...' | Automatic — component registry handles it |
| Syntax | JSX mixed with markdown (confusing for non-developers) | Directive syntax (consistent, learnable) |
| Portability | MDX files are meaningless outside MDX-aware tools | Directives degrade to readable text in any markdown viewer |
| Build coupling | Requires MDX compiler in build chain | Standard remark/rehype — no additional compiler |
| Content/code boundary | Blurred — authors can write arbitrary JS | Clean — authors write content, components are registered by developers |
| AI authoring | AI models struggle with mixed JSX/markdown | AI models handle directive syntax naturally |
Children as content: Container directives pass their body as slot content to the component. This is powerful — the component receives parsed markdown that it can render however it wants:
:::hero-section{background="/images/hero.jpg" overlay="dark"}
# Welcome to Metabologic
The future of metabolic health is enzyme-based, affordable, and accessible.
:badge[Now Accepting Beta Users]{variant="success"}
:::
The HeroSection.astro component receives the inner markdown (heading, paragraph, badge) as its default slot, rendered by a nested AstroMarkdown call. The component provides the layout, background image, and overlay — the content comes from the markdown.
Validation: Unknown directive names (not in the built-in registry AND not in the site’s custom registry) produce a build-time warning:
WARNING: Unknown directive "pricng-table" at line 42 in market-overview.md
Did you mean "pricing-table"? (registered in markdown-components.ts)
The fuzzy-match suggestion helps catch typos. In production, unknown directives render as a neutral <div> with a data-unknown-directive attribute for debugging.
5. Frontmatter Schema
The flavor defines a recommended frontmatter schema that content collections can validate against. Not all fields are required — collections define their own Zod schemas — but the flavor recommends these fields:
---
# Identity
title: string (required)
lede: string (1-2 sentence summary)
slug: string (URL-safe, auto-generated from title if omitted)
# Dates (ISO 8601)
date_created: YYYY-MM-DD
date_modified: YYYY-MM-DD
date_published: YYYY-MM-DD
# Authorship
authors: string[]
augmented_with: string (AI tool used, if any)
# Classification
tags: string[]
category: string
status: Draft | Review | Published | Archived
# Versioning
at_semantic_version: string (e.g., "0.0.0.1")
# Display
image: string (hero/OG image path)
image_prompt: string (AI image generation prompt, for documentation)
layout: string (override default layout)
# Behavioral
publish: boolean (default true)
toc: boolean (default true for long documents)
---
6. Shared Package Architecture
6.1 The Problem With How We Share Code Today
Right now, each of the 5-7 sites we maintain has its own copy of the remark/rehype plugins. When we fix a bug in remark-directives.ts in one site, we have to remember to copy it to the others. We usually don’t. The result is that Hypernova has one version of the citation parser, Dark-Matter has a slightly different one, mpstaton-site has a third, and the Lossless site (the most mature) has a fourth that’s diverged the furthest. Every site is slowly drifting apart.
The astro-knots monorepo was supposed to help with this via the copy-pattern philosophy, but in practice “copy when you remember” means “never copy.” We need an actual package.
6.2 Architectural Choice: Plugin Assembly vs. Owning the Parser
Before deciding how to distribute the package, we need to decide what’s in it. There are two fundamentally different approaches:
Option A — Plugin assembly (the conventional approach):
@lossless-group/lfm is a preset that installs and configures ~12 remark/rehype plugins from the unified ecosystem. Our package is thin glue code — it pulls in remark-gfm, remark-directive, @shikijs/rehype, unist-util-visit, etc., wires them together with opinionated defaults, and adds our custom plugins (citations, auto-unfurl, polyglot parsers) on top.
@lossless-group/lfm
├── depends on remark-parse (CommonMark parser)
├── depends on remark-gfm (tables, task lists, strikethrough)
├── depends on remark-directive (:::directive syntax)
├── depends on remark-rehype (MDAST → HAST bridge)
├── depends on rehype-raw (HTML passthrough)
├── depends on @shikijs/rehype (syntax highlighting)
├── depends on unist-util-visit (tree walker)
├── depends on rehype-stringify (HTML output)
└── our code: citations, callouts, auto-unfurl, polyglot parsers, validation
Pros: Battle-tested parsers, community-maintained, familiar to anyone who’s used unified.
Cons: Deep dependency tree (~50+ transitive packages), version conflicts between unified ecosystem versions (unified@10 vs @11), debugging through multiple abstraction layers, we inherit every upstream maintainer’s opinions and breaking changes.
Option B — Own the extension parser, minimize dependencies (our preference):
@lossless-group/lfm depends on a CommonMark parser for the genuinely hard base-level parsing, and then does everything else ourselves in a single, readable codebase with no additional dependencies.
@lossless-group/lfm
├── peer dependency: remark-parse OR markdown-it (CommonMark + GFM baseline)
├── peer dependency: shiki (syntax highlighting — genuinely complex, worth the dep)
└── our code: EVERYTHING ELSE
├── directive parser (~150 lines)
├── Markdoc tag parser (~100 lines)
├── MDX-lite parser (~120 lines)
├── code-fence component router (~80 lines)
├── Obsidian callout transformer (~60 lines)
├── citation parser (~200 lines)
├── auto-unfurl URL detector (~100 lines)
├── CSS attribute parser (~80 lines)
├── TOC generator (~100 lines)
├── wikilink resolver (~80 lines)
├── tree walker utility (~50 lines — replaces unist-util-visit)
├── validation / linting (~200 lines)
└── types (~150 lines)
≈ 1,500 lines total
Pros: Near-zero dependency surface (2 peer deps instead of 12+ direct deps), no version conflicts, no transitive dependency bloat, debugging means reading our code not someone else’s, AI assistants can iterate on any part of the parser in minutes, the parsing code is the spec — there’s no disconnect between what we document and what runs.
Cons: We own the bugs, edge cases accumulate over time, less community leverage for features we haven’t thought of yet.
Why Option B makes sense now in a way it didn’t 3 years ago:
The unified/remark/rehype ecosystem was built in an era when writing parsers was expensive. A developer who needed directive syntax in markdown would spend days writing and debugging a parser, so depending on remark-directive (maintained by someone who already did that work) was an obvious win.
But the calculus has changed. With AI-assisted development:
- Writing a directive parser from scratch takes 20 minutes, not 2 days
- Iterating on edge cases takes one conversation, not a deep debugging session
- The “cost” of owning parser code has collapsed while the “cost” of managing dependencies has stayed the same (or gotten worse, as the ecosystem fragments across major versions)
The convenience that justified these plugins no longer outweighs the dependency management overhead they impose. Our extensions — directives, callouts, citations, embeds, auto-unfurl, Markdoc tags — are all fundamentally simple string-parsing tasks. The only genuinely complex parsing problems are CommonMark itself (keep the dependency) and syntax highlighting (keep Shiki). Everything else, we can write and maintain ourselves with less total effort than managing the plugin ecosystem.
The recommendation: Start with Option B. Use remark-parse + remark-gfm for the base CommonMark/GFM layer (that parsing is legitimately hard and well-tested). Use Shiki for syntax highlighting (same reasoning). Write everything else ourselves. If we later discover that some specific unified plugin does something we need that would be painful to replicate, we can add it as a targeted dependency — but the default posture is to own the code rather than depend on a package.
This also means @lossless-group/lfm is a lightweight package. It doesn’t pull in 50 transitive dependencies. It installs fast, builds fast, and has a dependency footprint comparable to remark-gfm itself rather than a bloated preset.
6.3 The Core Mechanism: A Trigger Map
Strip away all the architecture and the answer to “what is @lossless-group/lfm?” is embarrassingly simple:
It’s a config file that maps syntax patterns to behaviors. The developer-user defines the behaviors. Done.
The entire extension system reduces to a JSON or YAML file like this:
# lfm.triggers.yaml — this IS your extended markdown flavor
triggers:
# Container triggers: match opening/closing delimiters, capture children as markdown
- name: callout
syntax:
- pattern: ':::callout{$attrs}\n$children\n:::' # directive
- pattern: '> [!$type] $title\n$children' # obsidian
- pattern: '{% callout $attrs %}$children{% /callout %}' # markdoc
component: Callout
props:
type: { required: true, enum: [info, tip, warning, danger, note, success, quote, example] }
title: { required: false, type: string }
- name: details
syntax:
- pattern: ':::details{$attrs}\n$children\n:::'
component: Details
props:
title: { required: true, type: string }
open: { required: false, type: boolean, default: false }
# Leaf triggers: self-closing, no children
- name: youtube
syntax:
- pattern: '::youtube{$attrs}' # directive
- pattern: 'https://www.youtube.com/watch?v=$id' # bare URL (auto-unfurl)
- pattern: 'https://youtu.be/$id' # short URL
component: YouTubeEmbed
props:
id: { required: true, type: string }
start: { required: false, type: number }
- name: soundcloud
syntax:
- pattern: '::soundcloud{$attrs}'
- pattern: 'https://soundcloud.com/$path'
component: SoundCloudEmbed
props:
url: { required: true, type: string }
# Inline triggers: appear within paragraph text
- name: badge
syntax:
- pattern: ':badge[$content]{$attrs}'
component: Badge
inline: true
props:
variant: { required: false, enum: [default, success, warning, danger, date, version] }
- name: tooltip
syntax:
- pattern: ':tooltip[$content]{$attrs}'
component: Tooltip
inline: true
props:
content: { required: true, type: string }
# Code fence triggers: identifier string escapes syntax highlighting
- name: card-grid
syntax:
- pattern: ':::card-grid{$attrs}\n$children\n:::' # directive (markdown children)
- pattern: '```card-grid\n$raw\n```' # code fence (JSON/YAML content)
component: CardGrid
props:
columns: { required: false, type: number, default: 3 }
- name: mermaid
syntax:
- pattern: '```mermaid\n$raw\n```'
component: MermaidDiagram
skipHighlighting: true
# Dialog / chat UI: renders conversation as chat bubbles
- name: dialog
syntax:
- pattern: ':::dialog{$attrs}\n$children\n:::' # directive (markdown children)
- pattern: '```dialog\n$raw\n```' # code fence (YAML header + conversation)
component: Dialog
props:
participants: { required: true, type: string } # "Name=role, Name=role"
theme: { required: false, enum: [light, dark, auto] }
# Citation trigger: special syntax with definition pairs
- name: citation
syntax:
- pattern: '[^$hexcode]' # inline reference
definition:
- pattern: '[^$hexcode]: $definition' # footnote-style definition
component: InlineCitation
inline: true
That’s it. That’s the whole extension system. A developer who wants to add a new component to their markdown just adds a trigger entry — a name, one or more syntax patterns, a component reference, and a props schema. The LFM parser reads this file, scans the markdown for matches, and produces component nodes. The rendering layer maps component names to actual component files.
What the LFM package provides:
- A parser that reads this trigger map and knows how to match the syntax patterns against markdown text
- A handful of built-in triggers (the ones in this spec) shipped as a default
lfm.triggers.yaml - A way for sites to extend or override the trigger map with their own entries
- The normalized component node type that all triggers produce
What the developer-user provides:
- The actual components (Astro, Svelte, React, whatever)
- Any custom trigger entries for their domain-specific components
- The rendering layer that maps component names to component files (or uses the default AstroMarkdown recursive renderer)
The trigger map is the single source of truth for what syntax does what. It replaces:
- The directive registry (section 13.4)
- The component mapping table (section 7.2)
- The auto-unfurl platform table (section 4.12)
- The code fence component identifier list (section 4.3.1)
- Half the validation logic (props schema is right there in the trigger)
And because it’s a YAML file, not code, a content author or project manager can read it and understand exactly what extended syntax their site supports — without reading a line of TypeScript.
Configuration layering:
@lossless-group/lfm built-in triggers (the defaults — callouts, embeds, citations, etc.)
↓ merged with
Site-level lfm.triggers.yaml (custom components, overrides)
↓ merged with
Collection-level overrides (optional — e.g., slide content enables slide triggers)
A site that just wants the defaults:
import { remarkLfm } from '@lossless-group/lfm';
// Uses built-in triggers. Done. That's the whole setup.
A site that adds custom triggers:
import { remarkLfm } from '@lossless-group/lfm';
remarkLfm({
triggers: './lfm.triggers.yaml', // extends built-in triggers with site-specific ones
});
A site that wants to start from scratch:
import { remarkLfm } from '@lossless-group/lfm';
remarkLfm({
triggers: './lfm.triggers.yaml',
builtins: false, // don't load default triggers — only use what's in the file
});
6.4 Distribution Strategy: Published Package, Not Workspace Dependency
UPDATE (2026-03-30): This section has been revised to reflect actual implementation. The workspace-only phase was skipped — the package is published to both GitHub Packages and JSR. Sites install it as a real dependency, not via
workspace:*.
Critical constraint: Each site must be independently deployable from its own repository. Sites are git submodules — they cannot depend on the monorepo’s packages/ directory at deploy time. Therefore, @lossless-group/lfm is a published package that sites install like any other dependency.
Why @lossless-group and not @lossless? GitHub Packages requires the npm scope to match the GitHub org name. Our org is lossless-group, so the scope is @lossless-group. This applies to both GitHub Packages and JSR.
Current state (as of 2026-03-30):
The package is published to two registries:
| Registry | URL | Auth Required to Install |
|---|---|---|
| GitHub Packages | npm.pkg.github.com | Yes (GITHUB_TOKEN) |
| JSR | jsr.io/@lossless-group/lfm | No |
Source code lives in the astro-knots monorepo at packages/lfm/. Development happens there.
How sites consume the package:
Each site’s package.json:
{
"dependencies": {
"@lossless-group/lfm": "^0.1.0"
}
}
Each site’s .npmrc (tells pnpm where to find @lossless-group packages):
@lossless-group:registry=https://npm.pkg.github.com
//npm.pkg.github.com/:_authToken=${GITHUB_TOKEN}
Publishing workflow (from packages/lfm/):
# Bump version in package.json and deno.json
# Build and publish to GitHub Packages
pnpm build
pnpm publish
# Publish to JSR (TypeScript source directly, no build step needed)
pnpx jsr publish --allow-dirty
Why two registries?
- GitHub Packages is the primary distribution channel — works with pnpm, ties into our existing GitHub infrastructure, supports private packages if needed.
- JSR (jsr.io) is the modern, TypeScript-first registry — publishes source directly (no build step), auto-generates documentation, works with Deno and Bun natively. As JSR matures, it may become the primary channel.
Future: npm public registry
When the package is stable enough for external users, we can additionally publish to the public npm registry. Nothing changes for our sites — just an additional publish target.
6.2.1 Rendering Layer Distribution: Parser vs. Renderer Boundary
The architectural split: @lossless-group/lfm is a parser — it takes markdown and produces an MDAST tree. Turning that tree into HTML requires a renderer, which is framework-specific (Astro components, Svelte components, React components, etc.) and inherently site-customizable (every site wants different styling, layout, and component choices).
This creates a distribution question: how do sites get the rendering layer?
Current state (as of 2026-04): Each site owns its own AstroMarkdown.astro renderer and directive-specific components (MarkdownImage.astro, Callout.astro, CodeBlock.astro, etc.). These are copied from reference implementations in packages/astro/ and adapted per-site.
Three options under consideration:
Option A: CLI Scaffolding
A pnpx @lossless-group/lfm init command that copies starter .astro files into the site’s src/components/markdown/ directory. The site then owns the files and can customize freely.
pnpx @lossless-group/lfm init --framework astro
# Creates:
# src/components/markdown/AstroMarkdown.astro
# src/components/markdown/MarkdownImage.astro
# src/components/markdown/Callout.astro
# src/components/markdown/CodeBlock.astro
Pros: Aligned with copy-and-adapt philosophy. Site owns the code. No runtime dependency on a component package. Authors can see and modify everything.
Cons: No automatic updates when new features are added to LFM. Sites diverge over time (which may be desirable or not). Requires maintaining a CLI tool.
Option B: Published Astro Integration Package
A separate @lossless-group/lfm-astro package that exports .astro components as importable dependencies. Sites import components rather than copying them.
import { AstroMarkdown } from '@lossless-group/lfm-astro';
import { MarkdownImage, Callout, CodeBlock } from '@lossless-group/lfm-astro/components';
Pros: Sites automatically get new features and bug fixes via version bumps. Single source of truth for rendering behavior. Less per-site maintenance.
Cons: Harder to customize per-site — every styling override requires the component to support it via props or CSS custom properties. Creates a second published package to maintain. Fights the principle that rendering is inherently site-specific. Astro component packages have some ecosystem friction (not all bundlers handle .astro imports from node_modules cleanly).
Option C: Pattern Package (@knots/astro)
Keep rendering components in the existing packages/astro/ workspace package as reference implementations. Sites copy from there when setting up markdown rendering. This is the current @knots/* copy-pattern approach.
# Copy the reference implementation into your site
cp packages/astro/src/components/markdown/*.astro sites/my-site/src/components/markdown/
# Adapt to your site's needs
Pros: Already how the project works. No new tooling or packages needed. Reference implementations are always visible in the monorepo for comparison.
Cons: “Copy when you remember” tends toward “never copy.” No mechanism for notifying sites when reference implementations improve. Only works for developers working within the astro-knots monorepo — external users can’t access packages/astro/.
Which Option When?
These options are not mutually exclusive:
- For internal sites (astro-knots monorepo): Option C works fine — developers can see and copy from
packages/astro/. - For external adopters: Option A (CLI scaffolding) is the friendliest onboarding experience.
- For teams that want managed updates: Option B provides the tightest integration, at the cost of customizability.
The recommendation is to start with Option C (which already works) and add Option A when external adoption becomes a goal. Option B should only be pursued if demand from external users justifies the maintenance cost of a second published package.
6.3 Package Structure
packages/lfm/
├── src/
│ ├── index.ts # Main preset exports: remarkLfm, rehypeLfm
│ ├── remark/
│ │ ├── polyglot.ts # Polyglot syntax normalizer (directives, Markdoc, MDX-lite, code fences)
│ │ ├── gfm.ts # remarkGfm configuration
│ │ ├── directives.ts # remarkDirective + directive validation + registry
│ │ ├── markdoc.ts # {% tag %} parser → normalized component nodes
│ │ ├── mdx-lite.ts # <Component /> parser (restricted, no JS expressions)
│ │ ├── code-components.ts # Code fence identifier → component node transform
│ │ ├── callouts.ts # Obsidian callout → directive transform
│ │ ├── citations.ts # Hex-code citation processing
│ │ ├── auto-unfurl.ts # Bare URL → embed directive transform
│ │ ├── backlinks.ts # Wikilink / backlink resolution
│ │ ├── toc.ts # Table of contents generation
│ │ ├── slides.ts # Slide separator handling
│ │ ├── images.ts # Image path resolution
│ │ └── css-attrs.ts # {.class #id style="..."} attribute parsing
│ ├── rehype/
│ │ ├── shiki.ts # Syntax highlighting config
│ │ ├── mermaid-pre.ts # Mermaid extraction (before Shiki)
│ │ ├── scoped-css.ts # ```css scoped → <style> injection
│ │ ├── autolink.ts # Heading anchor links
│ │ └── raw.ts # Raw HTML passthrough
│ ├── types.ts # TypeScript types for all custom MDAST nodes
│ ├── registry.ts # Built-in directive/component registry
│ └── validate.ts # Build-time syntax validation + linting
├── test/
│ ├── fixtures/ # Input markdown files for each feature
│ │ ├── callouts.md
│ │ ├── citations.md
│ │ ├── embeds.md
│ │ ├── code-components.md
│ │ └── ...
│ ├── snapshots/ # Expected MDAST output for each fixture
│ └── *.test.ts # Vitest test files
├── package.json
├── tsconfig.json
├── tsup.config.ts # Build config (ESM + CJS dual output)
├── CHANGELOG.md
├── LICENSE # MIT
└── README.md # Usage docs, quick-start, link to full spec
6.4 package.json
{
"name": "@lossless-group/lfm",
"version": "0.1.0",
"description": "Lossless Flavored Markdown — a polyglot extended markdown pipeline for remark/rehype",
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.js",
"require": "./dist/index.cjs",
"types": "./dist/index.d.ts"
},
"./remark/*": {
"import": "./dist/remark/*.js",
"types": "./dist/remark/*.d.ts"
},
"./rehype/*": {
"import": "./dist/rehype/*.js",
"types": "./dist/rehype/*.d.ts"
},
"./validate": {
"import": "./dist/validate.js",
"types": "./dist/validate.d.ts"
},
"./types": {
"import": "./dist/types.js",
"types": "./dist/types.d.ts"
},
"./registry": {
"import": "./dist/registry.js",
"types": "./dist/registry.d.ts"
}
},
"files": ["dist", "README.md", "LICENSE", "CHANGELOG.md"],
"scripts": {
"build": "tsup",
"dev": "tsup --watch",
"test": "vitest run",
"test:watch": "vitest",
"lint": "tsc --noEmit",
"prepublishOnly": "pnpm build && pnpm test"
},
"peerDependencies": {
"unified": ">=10.0.0",
"remark-parse": ">=10.0.0",
"remark-gfm": ">=4.0.0",
"shiki": ">=1.0.0"
},
"peerDependenciesMeta": {
"shiki": { "optional": true }
},
"dependencies": {},
"devDependencies": {
"tsup": "^8.0.0",
"typescript": "^5.0.0",
"vitest": "^1.0.0"
},
"keywords": [
"markdown",
"remark",
"rehype",
"unified",
"extended-markdown",
"directives",
"callouts",
"citations",
"astro",
"obsidian",
"markdoc"
],
"repository": {
"type": "git",
"url": "https://github.com/lossless-group/lfm"
},
"license": "MIT",
"author": "Michael Staton <michael@lossless.group>"
}
6.5 Build Configuration
We use tsup for building because it handles ESM + CJS dual output, tree-shaking, and declaration files with zero config:
// tsup.config.ts
import { defineConfig } from 'tsup';
export default defineConfig({
entry: {
'index': 'src/index.ts',
'validate': 'src/validate.ts',
'types': 'src/types.ts',
'registry': 'src/registry.ts',
'remark/polyglot': 'src/remark/polyglot.ts',
'remark/directives': 'src/remark/directives.ts',
'remark/citations': 'src/remark/citations.ts',
'remark/callouts': 'src/remark/callouts.ts',
'remark/auto-unfurl': 'src/remark/auto-unfurl.ts',
'remark/backlinks': 'src/remark/backlinks.ts',
'remark/toc': 'src/remark/toc.ts',
'remark/code-components': 'src/remark/code-components.ts',
'remark/css-attrs': 'src/remark/css-attrs.ts',
'rehype/shiki': 'src/rehype/shiki.ts',
'rehype/mermaid-pre': 'src/rehype/mermaid-pre.ts',
'rehype/scoped-css': 'src/rehype/scoped-css.ts',
},
format: ['esm', 'cjs'],
dts: true,
clean: true,
splitting: true,
treeshake: true,
});
This gives consumers tree-shakeable imports — if a site only uses citations and callouts, the rest of the package is dead-code-eliminated.
6.6 Usage
As a preset (recommended — use everything):
import { unified } from 'unified';
import remarkParse from 'remark-parse';
import remarkRehype from 'remark-rehype';
import rehypeStringify from 'rehype-stringify';
import { remarkLfm, rehypeLfm } from '@lossless-group/lfm';
const processor = unified()
.use(remarkParse)
.use(remarkLfm, {
citations: true,
backlinks: true,
callouts: true,
autoUnfurl: true,
polyglot: {
markdoc: true, // parse {% tag %} syntax
mdxLite: false, // don't parse <Component /> syntax
codeFenceComponents: ['card-grid', 'card-carousel', 'image-grid'],
},
slides: false, // opt-out per feature
toc: { minDepth: 2, maxDepth: 4 },
customDirectives: [
// site-specific directives
],
})
.use(remarkRehype)
.use(rehypeLfm, {
shiki: { theme: 'tokyo-night' },
mermaid: true,
scopedCss: true,
})
.use(rehypeStringify);
Cherry-picking (for sites that want surgical control):
import { remarkCitations } from '@lossless-group/lfm/remark/citations';
import { remarkCallouts } from '@lossless-group/lfm/remark/callouts';
import { remarkAutoUnfurl } from '@lossless-group/lfm/remark/auto-unfurl';
import { rehypeMermaidPre } from '@lossless-group/lfm/rehype/mermaid-pre';
In Astro config (the most common integration point):
// astro.config.mjs
import { remarkLfm } from '@lossless-group/lfm';
export default defineConfig({
markdown: {
remarkPlugins: [
[remarkLfm, { citations: true, callouts: true }],
],
// Disable Astro's built-in Shiki — LFM handles it
syntaxHighlight: false,
},
});
6.7 MDAST Node Types
The package exports TypeScript types for all custom nodes so that AstroMarkdown components (or any consumer) can type-check their rendering logic:
// Extended MDAST node types
interface CalloutNode extends Parent {
type: 'containerDirective';
name: 'callout';
attributes: {
type: 'info' | 'tip' | 'warning' | 'danger' | 'note' | 'success' | 'quote' | 'example';
title?: string;
};
}
interface CitationRefNode extends Literal {
type: 'footnoteReference';
identifier: string; // hex code, e.g. "1ucdcd"
resolvedIndex?: number; // sequential integer assigned at render time
}
interface BadgeNode extends Parent {
type: 'textDirective';
name: 'badge';
attributes: {
variant?: 'default' | 'success' | 'warning' | 'danger' | 'date' | 'version';
};
}
interface ComponentNode extends Parent {
// The unified node type produced by ALL trigger syntaxes (directive, Markdoc, MDX-lite, code fence)
type: 'componentNode';
name: string; // e.g. "callout", "youtube", "card-grid"
attributes: Record<string, string>; // key-value props (always strings)
triggerSyntax: 'directive' | 'markdoc' | 'mdx-lite' | 'code-fence' | 'obsidian-callout' | 'auto-unfurl';
rawContent?: string; // for code fence triggers: the fence body as a string
}
// ... more types for each feature
The ComponentNode type is the key — it’s the unified representation that all trigger syntaxes normalize to. The triggerSyntax field is metadata for debugging (“which syntax produced this node?”) but the rendering layer should never branch on it.
6.8 Validation Mode
import { validateLfm } from '@lossless-group/lfm/validate';
const warnings = validateLfm(markdownString, {
strictDirectives: true, // Warn on unrecognized directive names
checkCitations: true, // Warn on [^hex] without definitions
checkLinks: false, // Skip link checking
customDirectives: ['investment-thesis', 'key-risk'], // site-specific names to allow
});
// warnings: [
// { line: 42, message: "Unknown directive ':foobar'. Did you mean ':figma'?" },
// { line: 87, message: "Citation [^abc123] has no definition" },
// ]
6.9 Versioning Strategy
We use semantic versioning with a practical interpretation for a markdown pipeline:
| Change Type | Version Bump | Example |
|---|---|---|
| New trigger syntax parser | Minor | Adding Markdoc parser (0.2.0 → 0.3.0) |
| New built-in directive | Minor | Adding ::spotify embed (0.3.0 → 0.4.0) |
| Bug fix in existing plugin | Patch | Fix citation parser edge case (0.3.0 → 0.3.1) |
| Change to normalized node shape | Major | Renaming attributes to props (0.x → 1.0.0) |
| Change that alters rendered output | Major | Callout HTML structure change (0.x → 1.0.0) |
| New optional config flag | Patch | Adding polyglot.markdoc option (0.3.0 → 0.3.1) |
Pre-1.0 reality: While we’re pre-1.0, minor versions may include breaking changes. This is standard npm convention. We’ll try to avoid it but won’t contort the API to prevent it. Once we hit 1.0 (after stable use across all our sites + at least one external user), semver is strict.
6.10 Publishing Workflow
For internal releases (GitHub Packages):
# In packages/lfm/
pnpm version patch # or minor, or major
pnpm build
pnpm test
pnpm publish --registry https://npm.pkg.github.com
For public releases (npm):
pnpm version patch
pnpm build
pnpm test
pnpm publish --access public
Automated (when we have CI — Phase 3+):
A GitHub Action on the packages/lfm path triggers on push to main:
- Run tests
- If
package.jsonversion changed, publish to GitHub Packages - If a git tag like
lfm@0.3.0is pushed, also publish to npm public
6.11 Repository Strategy
Option A — Monorepo package (recommended for now):
@lossless-group/lfm lives at astro-knots/packages/lfm/. This keeps it close to the sites that consume it, makes development trivial (edit package → reload site), and avoids managing another repo.
Option B — Standalone repo (recommended for public release):
When/if we publish to npm public, move the package to its own repo at github.com/lossless-group/lfm. This gives it:
- Its own issue tracker (people can file bugs against the package, not the monorepo)
- Its own CI/CD pipeline
- Its own README/docs that aren’t buried in a monorepo
- A clean git history focused on the package
The migration from A to B is straightforward: git subtree split or just copy the directory, push to the new repo, and update the sites to depend on the published version instead of workspace:*.
We start with Option A and graduate to Option B when external interest justifies it.
6.12 How Sites Consume the Package (Summary)
| Context | How the Site Gets @lossless-group/lfm | Version |
|---|---|---|
| Local dev in monorepo | pnpm workspace protocol (workspace:*) | Always latest from source |
| Site deploying from its own repo (Vercel) | npm install from GitHub Packages or npm | Pinned semver (e.g., ^0.3.0) |
| External user | npm install from public npm registry | Pinned semver |
| Copy-pattern holdouts | Copy packages/lfm/src/ into their site | Whatever they copied, frozen in time |
The copy-pattern option remains available for sites that refuse dependencies or need to diverge. But the recommendation is: use the package. That’s the whole point.
7. Rendering Architecture
The flavor defines the pipeline, but rendering is done by Astro components. Each site owns its renderer.
7.1 Pipeline Flow
Content (Markdown + Frontmatter)
│
┌───────────┴───────────┐
│ remarkParse │
│ (CommonMark → MDAST) │
└───────────┬───────────┘
│
┌───────────┴───────────┐
│ remarkLfm Preset │
│ ┌─────────────────┐ │
│ │ remarkGfm │ │
│ │ remarkDirective │ │
│ │ remarkCallouts │ │
│ │ remarkCitations │ │
│ │ remarkBacklinks │ │
│ │ remarkToc │ │
│ └─────────────────┘ │
└───────────┬───────────┘
│
Extended MDAST
(standard + custom nodes)
│
┌─────────────────┼─────────────────┐
│ │ │
┌─────────┴──────┐ ┌──────┴───────┐ ┌──────┴───────┐
│ AstroMarkdown │ │ rehypeLfm + │ │ Validation │
│ (component │ │ stringify │ │ (build-time │
│ rendering) │ │ (HTML output)│ │ warnings) │
└────────────────┘ └──────────────┘ └──────────────┘
7.2 AstroMarkdown Component Mapping
The recursive AstroMarkdown.astro component maps MDAST nodes to Astro components:
| MDAST Node Type | Directive Name | Astro Component | Source |
|---|---|---|---|
heading | — | <h1>..<h6> with id + anchor | Built-in |
code | — | CodeBlock.astro | Built-in |
code (css scoped) | — | <style> injection (not rendered as code block) | Built-in |
containerDirective | callout | Callout.astro | Built-in |
containerDirective | details | Details.astro | Built-in |
containerDirective | image-gallery | ImageGallery.astro | Built-in |
containerDirective | columns | Columns.astro | Built-in |
containerDirective | tabs | Tabs.astro | Built-in |
containerDirective | steps | Steps.astro | Built-in |
containerDirective | timeline | Timeline.astro | Built-in |
containerDirective | (any registered name) | (site’s component registry) | Auto-component |
leafDirective | image | MarkdownImage.astro | Built-in |
leafDirective | youtube | YouTubeEmbed.astro | Built-in / auto-unfurl |
leafDirective | soundcloud | SoundCloudEmbed.astro | Built-in / auto-unfurl |
leafDirective | figma | FigmaEmbed.astro | Built-in / auto-unfurl |
leafDirective | loom | LoomEmbed.astro | Built-in / auto-unfurl |
leafDirective | tweet | TweetEmbed.astro | Built-in / auto-unfurl |
leafDirective | spotify | SpotifyEmbed.astro | Built-in / auto-unfurl |
leafDirective | vimeo | VimeoEmbed.astro | Built-in / auto-unfurl |
leafDirective | codepen | CodePenEmbed.astro | Built-in / auto-unfurl |
leafDirective | embed | GenericEmbed.astro (sandboxed iframe) | Built-in |
leafDirective | page-break | CSS break-before: page | Built-in |
leafDirective | (any registered name) | (site’s component registry) | Auto-component |
textDirective | badge | Badge.astro | Built-in |
textDirective | tooltip | Tooltip.astro | Built-in |
textDirective | sidenote | Sidenote.astro | Built-in |
footnoteReference | — | InlineCitation.astro | Built-in |
paragraph (single URL) | — | Platform-specific embed (auto-unfurl) | Built-in |
Block with {.class} | — | Adds CSS class to rendered element | Built-in |
Block with {style="..."} | — | Adds inline styles to rendered element | Built-in |
The Source column distinguishes between:
- Built-in: Ships with
@lossless-group/lfm, always available - Auto-unfurl: Bare URLs on their own line are automatically converted to the corresponding embed directive
- Auto-component: Directive names not in the built-in list are looked up in the site’s component registry (see 4.31)
8. Content Authoring Guide (For Humans and AI)
This section is the “cheat sheet” that content authors (and AI assistants generating markdown) should reference.
8.1 Quick Reference Card
# Heading 1
## Heading 2
**bold** *italic* ~~strikethrough~~ `inline code`
- Unordered list
1. Ordered list
- [x] Task (done)
- [ ] Task (not done)
[Link text](url)

> Blockquote
---
| Col A | Col B |
|-------|-------|
| val | val |
:::callout{type="info" title="Note"}
Callout content.
:::
:badge[Label]{variant="success"}
Some claim.[^abc123]
[^abc123]: 2026. [Title](url). Published: 2026-03-25
```language
code block
graph TD
A --> B
:::details{title=“Expandable”} Hidden content. :::
::youtube{id=“video-id”} ::figma{url=“https://figma.com/…”}
Or just paste the URL — auto-unfurls:
https://www.youtube.com/watch?v=dQw4w9WgXcQ https://soundcloud.com/artist/track-name
:::image-gallery{columns=“3”}
:::
CSS: class annotations on blocks
My Heading {.accent-underline}
A styled paragraph. {.lead-text}
:::callout{type=“info” style=“background: linear-gradient(135deg, #667eea, #764ba2); color: white;”} Inline style overrides on any directive. :::
Auto-component rendering (any registered component):
:::pricing-table{tiers=“3” highlight=“pro”} :::
::team-grid{layout=“cards”}
### 8.2 Rules for AI-Generated Content
When an AI assistant (Claude, GPT, etc.) generates markdown content for our sites:
1. **Use directive syntax** for all extended features — not raw HTML, not MDX, not JSX
2. **Always include frontmatter** with at minimum `title`, `date_created`, `tags`
3. **Use hex-code citations** — never hardcode sequential numbers. Generate a 6-char hex code for each source and include the full `[^hexcode]: YYYY. [Title](URL). Published: YYYY-MM-DD` definition
4. **Prefer `:::callout` over `> [!type]`** — both work, but directives are canonical
5. **Fenced code blocks must have a language** — no bare triple-backticks
6. **Images should use the `::image` directive** when captions or sizing are needed
7. **For embeds, prefer bare URLs on their own line** — just paste the YouTube/SoundCloud/Vimeo URL on a blank line and the renderer handles it. Only use `::youtube{id="..."}` when you need attributes like `start` time
8. **Use `{.classname}` for styling hints** — prefer class annotations over inline `style` attributes when the site has relevant CSS classes defined
9. **For custom components, use the directive name** — `:::pricing-table{props}` not `<PricingTable props />`. The registry handles the mapping
10. **Do not use MDX syntax** — no JSX, no imports, no expressions, no `.mdx` extension
---
## 9. Compatibility Matrix
How our flavor interacts with common tools:
| Tool | Compatibility | Notes |
|------|-------------|-------|
| **VS Code** | Excellent | Standard markdown preview works; directives show as raw but don't break |
| **Obsidian** | Good | GFM + callouts render; directives show as raw text; wikilinks work natively |
| **GitHub** | Good | GFM features render; directives/citations show as raw text |
| **Notion import** | Partial | Basic markdown imports; extended features lost |
| **Astro (our sites)** | Full | Everything renders via our pipeline |
| **Pandoc** | Good | Most CommonMark/GFM works; directives need a filter |
| **AI assistants** | Good | Claude/GPT understand directive syntax when prompted |
**Graceful degradation**: The flavor is designed so that unsupported features degrade to readable text. A `:::callout{type="warning"}` in GitHub will show as a fenced block with the directive syntax visible — ugly but not broken.
---
## 10. Implementation Phases
### Phase 1: Codify and Extract — DONE (2026-03-26)
- ~~Write this spec~~
- ~~Create `packages/lfm/` in the astro-knots monorepo~~
- ~~Implement first plugin: `remarkCallouts` (Obsidian `> [!type]` → directive normalization)~~
- ~~Create `remarkLfm` preset wrapping remarkGfm + remarkDirective + remarkCallouts~~
- ~~Export `parseMarkdown()` convenience function~~
- ~~Get `pnpm build` passing~~
- Still TODO: Extract remaining plugins from `site/src/utils/markdown/` (citations, backlinks, images, toc, code-components)
- Still TODO: Add tests
### Phase 2: Publish and Wire Into First Site — DONE (2026-03-26)
- ~~Publish `@lossless-group/lfm@0.1.0` to GitHub Packages~~
- ~~Publish `@lossless-group/lfm@0.1.1` to JSR (jsr.io)~~
- ~~Wire into mpstaton-site as a real published dependency (not workspace:*)~~
- ~~Implement context-v detail page rendering using the package~~
- ~~Copy AstroMarkdown renderer into mpstaton-site (site-owned rendering layer)~~
- Still TODO: Wire into remaining sites (hypernova, cilantro, twf, dark-matter, cogs)
**Key decision made:** Skipped the workspace-only phase. Sites install the published package, not `workspace:*`. This preserves independent deployability.
### Phase 3: Wire Into All Sites (Current)
- Add `@lossless-group/lfm` to each site's `package.json`
- Copy the AstroMarkdown renderer (or a site-appropriate variant) into each site
- Replace per-site remark plugin copies with imports from the shared package
- Verify rendering parity — build each site, check for regressions
- Delete the per-site plugin copies once parity is confirmed
### Phase 4: Extract Remaining Plugins
- Port `remark-citations.ts` from `site/src/utils/markdown/` into the package
- Port `remark-backlinks.ts` (wikilink resolution)
- Port `remark-images.ts` (wiki-style image path normalization)
- Port `remark-toc.ts` (table of contents generation)
- Port `remark-code-components.ts` (code fence → component routing)
- Port `remark-directives.ts` (directive → component mapping with registry)
- Each plugin: extract → add to preset → test → bump version → publish
### Phase 5: Wish List Features and Stabilization
- Implement Wish List features based on author demand, one at a time
- Each feature goes through: remark plugin → MDAST type → AstroMarkdown branch → Astro component
- Each new feature starts as Beta, graduates to Stable after use across 2+ sites
- Build out the polyglot parsers (Markdoc, MDX-lite) as authors encounter those syntaxes
### Phase 6: Public npm Release (Optional)
- Additionally publish to the public npm registry if external interest justifies it
- Write a proper README with quick-start guide, feature gallery, and link to this spec
- Consider moving to its own repo (`github.com/lossless-group/lfm`) for independent issue tracking
---
## 11. Open Questions
1. **Naming**: Settled — **Lossless Flavored Markdown (LFM)**. Echoes the "GitHub Flavored Markdown (GFM)" convention. Package: `@lossless-group/lfm` (scoped to the GitHub org `lossless-group`). Published to both GitHub Packages and JSR.
2. **Obsidian callout parity**: Should we support ALL Obsidian callout types (there are ~13) or just our curated set? Obsidian has types like `abstract`, `todo`, `bug`, `failure` that we haven't needed.
3. **Wikilinks scope**: If we support `[[wikilinks]]`, what's the resolution scope? Within a single content collection? Across all collections in a site? Across sites?
4. **Math rendering**: KaTeX (faster, smaller) or MathJax (more complete, heavier)? For our use case (occasional formulas in investment memos), KaTeX is probably sufficient.
5. **Package distribution**: Settled — published to GitHub Packages and JSR from day one. No workspace:* phase — sites install the published package as a real dependency. See Section 6.4 for the full distribution strategy.
6. **Custom directive registration**: Should sites be able to register custom directive names that the shared package doesn't know about? If so, how does validation work?
7. **Frontmatter schema enforcement**: Should the shared package include Zod schemas for frontmatter, or is that purely a per-collection concern?
---
## 12. Related Documents
- `Maintain-Extended-Markdown-Render-Pipeline.md` — The pipeline architecture for Astro-Knots
- `Citation-System-Architecture.md` — Hex-code citation system design
- `Codeblock-Syntax-Highlighting-with-Shiki.md` — Shiki integration pattern
- `Slides-System-for-Astro-and-Markdown.md` — Presentation slides in markdown
- `Managing-Complex-Markdown-Content-at-Build-Time.md` — Content sourcing patterns
---
## 13. Directive System Deep Dive
The directive system is the heart of our extensibility. This section formalizes the grammar, nesting rules, attribute syntax, and registration model.
### 13.1 Formal Grammar
The directive syntax follows the [CommonMark Generic Directive Proposal](https://talk.commonmark.org/t/generic-directives-plugins-syntax/444) with minor conventions added:
TextDirective = ”:” name [ ”[” content ”]” ] [ ”{” attributes ”}” ] LeafDirective = ”::” name [ ”[” content ”]” ] [ ”{” attributes ”}” ] ContainerDirective = ”:::” name [ ”{” attributes ”}” ] NEWLINE body ”:::“
Where:
- `name` is a kebab-case identifier: `[a-z][a-z0-9-]*` (e.g., `callout`, `image-gallery`, `key-risk`)
- `content` is inline markdown (only meaningful for text directives)
- `attributes` is a space-separated list of `key="value"` pairs or bare flags
- `body` is arbitrary markdown (parsed recursively for container directives)
### 13.2 Attribute Syntax
Attributes follow HTML-like conventions:
```markdown
:::callout{type="warning" title="Watch Out" collapsible}
| Form | Example | Meaning |
|---|---|---|
| Key-value (quoted) | type="warning" | String attribute |
| Key-value (unquoted) | columns=3 | String attribute (no spaces allowed in value) |
| Bare flag | collapsible | Boolean true |
| Class shorthand | .highlight | Adds CSS class |
| ID shorthand | #my-section | Sets element ID |
Multiple classes and mixed forms are allowed:
:::callout{type="info" .custom-class #my-callout collapsible}
13.3 Nesting Rules
Container directives can nest using increasing colon counts:
::::columns{count="2"}
:::column
Left side content.
:::callout{type="tip"}
A callout nested inside a column.
:::
:::
:::column
Right side content.
:::
::::
Nesting depth limit: 4 levels (:::, ::::, :::::, ::::::). Deeper nesting is a sign of overly complex content and will trigger a validation warning.
Self-nesting: A directive CAN nest inside itself (e.g., :::details inside :::details) as long as the colon counts are different.
13.4 Directive Registry
The shared package maintains a registry of known directive names. Each entry defines:
interface DirectiveRegistryEntry {
name: string; // kebab-case name
type: 'text' | 'leaf' | 'container'; // which directive form(s) it supports
description: string; // human-readable description
attributes: {
[key: string]: {
type: 'string' | 'number' | 'boolean' | 'enum';
required?: boolean;
default?: any;
values?: string[]; // for enum type
description: string;
};
};
tier: 'stable' | 'beta' | 'wishlist';
since?: string; // version when added
component?: string; // suggested Astro component name
}
Built-in registry (ships with @lossless-group/lfm):
const builtinDirectives: DirectiveRegistryEntry[] = [
{
name: 'callout',
type: 'container',
description: 'Callout/admonition block with type-based styling',
attributes: {
type: { type: 'enum', required: true, values: ['info', 'tip', 'warning', 'danger', 'note', 'success', 'quote', 'example'], description: 'Visual style' },
title: { type: 'string', required: false, description: 'Override default title' },
collapsible: { type: 'boolean', required: false, default: false, description: 'Make content toggleable' },
},
tier: 'stable',
component: 'Callout.astro',
},
{
name: 'details',
type: 'container',
description: 'Collapsible content section',
attributes: {
title: { type: 'string', required: true, description: 'Summary text shown when collapsed' },
open: { type: 'boolean', required: false, default: false, description: 'Start expanded' },
},
tier: 'stable',
component: 'Details.astro',
},
{
name: 'badge',
type: 'text',
description: 'Inline styled label',
attributes: {
variant: { type: 'enum', required: false, values: ['default', 'success', 'warning', 'danger', 'date', 'version'], default: 'default', description: 'Color variant' },
},
tier: 'stable',
component: 'Badge.astro',
},
{
name: 'youtube',
type: 'leaf',
description: 'Embedded YouTube video',
attributes: {
id: { type: 'string', required: true, description: 'YouTube video ID' },
start: { type: 'number', required: false, description: 'Start time in seconds' },
},
tier: 'beta',
component: 'YouTubeEmbed.astro',
},
// ... full registry in package source
];
Custom directive registration (per-site):
import { remarkLfm } from '@lossless-group/lfm';
// Site registers domain-specific directives
const processor = unified()
.use(remarkParse)
.use(remarkLfm, {
customDirectives: [
{
name: 'investment-thesis',
type: 'container',
description: 'Highlighted investment thesis block',
attributes: {},
tier: 'stable',
component: 'InvestmentThesis.astro',
},
{
name: 'key-risk',
type: 'container',
description: 'Risk callout with severity',
attributes: {
severity: { type: 'enum', values: ['low', 'medium', 'high', 'critical'], required: false, default: 'medium', description: 'Risk severity' },
},
tier: 'stable',
component: 'KeyRisk.astro',
},
],
});
Validation will accept both built-in and custom-registered directive names. Unknown directives trigger a warning (not an error) to allow gradual adoption.
13.5 Directive-to-Component Contract
The remark layer does NOT render directives. It transforms them into well-typed MDAST nodes and passes them downstream. The rendering layer (AstroMarkdown or rehype) is responsible for mapping directive names to actual components.
The contract:
- Remark layer guarantees: Every directive node in the MDAST has
name,attributes(validated), andchildren(parsed markdown for containers) orvalue(raw text for leaves) - Rendering layer guarantees: Every registered directive name maps to exactly one component. Unknown directives render as a visible warning block in development and are silently skipped in production
- Neither layer assumes the other: You can swap rendering layers (e.g., use rehype-stringify instead of AstroMarkdown) without changing the remark pipeline
14. Print / PDF Behavior
Every feature must degrade gracefully to print. Our sites use window.print() + CSS @page rules for PDF generation. This means interactive features must have print-friendly fallbacks.
14.1 Print Behavior Matrix
| Feature | Print Behavior | Notes |
|---|---|---|
| Headings | Normal | Include anchors as invisible targets for TOC links |
| Code blocks | Dark background preserved | Ensure background-color is not stripped by @media print |
| Mermaid diagrams | Rendered as static SVG | Mermaid generates SVG — print captures it as-is |
| Callouts | Full rendering with colored left border | Background colors may be stripped by some browsers; border-left is reliable |
| Badges | Inline with border instead of background | Background colors unreliable in print; switch to border + color |
| Citations | Superscript numbers, no hover popover | Popover JS is irrelevant in print; Sources section at bottom is critical |
| Tables | Full rendering with borders | Ensure border-collapse and cell padding are print-explicit |
| Details/collapsible | Forced open | All <details> elements set to open via @media print |
| Embeds (YouTube, etc.) | Link + thumbnail | Replace iframe with a linked thumbnail image and URL |
| Image gallery | Grid layout preserved | May need break-inside: avoid on gallery items |
| TOC | Inline rendering, no sticky sidebar | Sidebar hidden; inline TOC visible |
| Tabs | All tabs visible, stacked | Print shows all tab contents sequentially |
| Columns | Single column | Multi-column collapses to single column for narrow print |
| Timeline | Linear list with dates | Timeline visual simplified to a styled list |
| Tooltips | Content shown inline in parentheses | The company uses CRISPR (Clustered Regularly... ) for its core platform. |
| Sidenotes | Inline in parentheses | Marginal notes collapse to inline |
14.2 Print-Specific CSS Pattern
@media print {
/* Force all details open */
details { display: block !important; }
details > summary { display: none; }
details > *:not(summary) { display: block !important; }
/* Hide interactive-only elements */
.copy-button, .toc-sidebar, .mobile-toc-container { display: none !important; }
/* Force callout backgrounds (some browsers strip them) */
.callout { border-left: 4px solid currentColor; padding-left: 1rem; }
/* Embeds → linked thumbnails */
.embed-container iframe { display: none; }
.embed-container .print-fallback { display: block !important; }
/* Tabs → all visible */
.tab-panel { display: block !important; }
.tab-panel::before { content: attr(data-tab-label); font-weight: 700; }
/* Columns → single column */
.columns-container { display: block !important; }
.columns-container > .column { margin-bottom: 1rem; }
/* Sidenotes → inline */
.sidenote { display: inline; }
.sidenote::before { content: " ("; }
.sidenote::after { content: ") "; }
/* Tooltips → inline content */
.tooltip .tooltip-content { display: inline !important; }
.tooltip .tooltip-content::before { content: " ("; }
.tooltip .tooltip-content::after { content: ") "; }
/* Code blocks: preserve dark background */
pre.shiki {
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
background-color: #1a1b26 !important;
}
}
14.3 Page Break Hints
Authors can suggest page breaks for print/PDF:
::page-break
And prevent breaks inside a block:
:::no-break
This content should stay together on one page.
Including this table and the paragraph above it.
:::
CSS implementation:
.page-break { break-before: page; }
.no-break { break-inside: avoid; }
15. Accessibility Requirements
Every rendered feature must meet WCAG 2.1 AA at minimum.
15.1 Per-Feature Accessibility
| Feature | Requirement | Implementation |
|---|---|---|
| Headings | Proper hierarchy (no skipping levels) | Validation warning if H2 follows H4 |
| Code blocks | Accessible to screen readers | role="code", aria-label with language, copy button labeled |
| Callouts | Semantic role | role="note" or role="alert" for danger/warning |
| Citations | Keyboard-navigable popovers | tabindex="0", role="button", aria-describedby linking to popover |
| Tables | Scope and headers | <th scope="col">, <caption> from directive title attribute |
| Details | Native <details>/<summary> | Built-in accessibility; ensure summary is descriptive |
| Embeds | Title attributes on iframes | <iframe title="YouTube: Video Title"> |
| Image gallery | Alt text required | Validation error if any gallery image lacks alt text |
| Tabs | ARIA tabs pattern | role="tablist", role="tab", role="tabpanel", arrow key navigation |
| Tooltips | ARIA tooltip pattern | aria-describedby, visible on focus (not just hover) |
| Mermaid | Alt text fallback | aria-label on SVG with diagram description |
| Badges | Color not sole indicator | Text content is the primary information; color is supplementary |
15.2 Validation Rules (Accessibility)
The linter/validator enforces:
WARNING: Image in gallery at line 42 has no alt text
WARNING: Heading skip: H2 at line 10, then H4 at line 25 (missing H3)
WARNING: Table at line 60 has no caption — consider adding {title="..."}
ERROR: Embed at line 80 has no accessible label
15.3 Color Contrast
All callout types, badges, and themed elements must meet 4.5:1 contrast ratio in both light and dark modes. The shared package ships a contrast check utility:
import { checkCalloutContrast } from '@lossless-group/lfm/a11y';
// Returns warnings for any callout type that fails contrast
const issues = checkCalloutContrast(siteThemeTokens);
16. Dark / Light / Vibrant Mode Behavior
Our sites support three visual modes. Every feature must work in all three.
16.1 Mode-Aware Rendering
| Feature | Dark Mode | Light Mode | Vibrant Mode |
|---|---|---|---|
| Code blocks | Dark (tokyo-night) | Dark (unchanged) | Dark (unchanged) |
| Callout backgrounds | Semi-transparent on dark surface | Semi-transparent on light surface | Semi-transparent on vibrant surface |
| Callout borders | Bright accent color | Darker accent color | Neon accent color |
| Badges | Light text on dark pill | Dark text on light pill | Light text on saturated pill |
| Citation markers | Lilac accent | Primary blue | Neon accent |
| Tables | Subtle row striping, light borders | Standard striping, darker borders | High-contrast striping |
| Mermaid diagrams | Dark theme variables | Light theme variables | Custom vibrant variables |
| Blockquotes | Dim border, muted text | Gray border, standard text | Accent border, bright text |
| Links | Underlined, accent color | Underlined, primary color | Underlined, neon color |
16.2 Implementation Pattern
Components use CSS custom properties tied to the data-mode attribute on <html>:
/* Callout component */
.callout {
border-left: 4px solid var(--callout-border-color);
background: var(--callout-bg-color);
}
:global([data-mode="dark"]) .callout--warning {
--callout-border-color: #f59e0b;
--callout-bg-color: rgba(245, 158, 11, 0.08);
}
:global([data-mode="light"]) .callout--warning {
--callout-border-color: #d97706;
--callout-bg-color: rgba(217, 119, 6, 0.06);
}
:global([data-mode="vibrant"]) .callout--warning {
--callout-border-color: #fbbf24;
--callout-bg-color: rgba(251, 191, 36, 0.12);
}
16.3 Mode Detection for Non-CSS Contexts
Mermaid diagrams, JSON Canvas, and other JS-rendered features need to know the current mode:
function getCurrentMode(): 'dark' | 'light' | 'vibrant' {
return document.documentElement.getAttribute('data-mode') as any || 'dark';
}
// Re-render on mode change
const observer = new MutationObserver(() => {
const mode = getCurrentMode();
mermaid.initialize({ theme: mode === 'light' ? 'default' : 'dark' });
mermaid.run();
});
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-mode'] });
17. Edge Cases and Gotchas
17.1 Directive Syntax Conflicts
Problem: A line starting with ::: could be confused with a container directive when the author just wants a visual separator.
Rule: ::: alone on a line (no name following) is treated as a thematic break, not a directive. This matches CommonMark behavior for ---.
Problem: Colon at start of list item looks like a text directive.
- :badge[New] This is fine — the `:` is clearly a text directive
- :not-a-directive — this will be parsed as a text directive with name "not-a-directive"
Rule: Unknown directive names produce a validation warning but are rendered as plain text in production. No silent failures.
17.2 Code Blocks Inside Directives
Fenced code blocks inside container directives require careful fence management:
:::callout{type="info"}
Here is some example code:
```javascript
const x = 1;
```
And here is more text after the code.
:::
This works because the triple-backtick fence is a different delimiter than :::. But if you use tildes (~~~) for code fences, the same rules apply.
Gotcha: Code blocks that contain directive-like syntax are NOT parsed as directives (they’re inside a code fence). This is standard CommonMark behavior and is correct.
17.3 Frontmatter Edge Cases
Multiple YAML documents: Only the first ----delimited block is frontmatter. A second --- in the body is a thematic break.
YAML type coercion: YAML true, false, null, and bare numbers are parsed as their respective types, not strings. If you want the string "true", quote it:
---
title: "true" # String "true", not boolean
count: "42" # String "42", not number
publish: true # Boolean true
---
Special characters in titles: YAML special characters (:, #, {, }) in values must be quoted:
---
title: "Markdown: A Complete Guide" # Correct
title: Markdown: A Complete Guide # YAML parse error
---
17.4 Citation Hex Code Collisions
With 6-character lowercase alphanumeric codes, there are ~2.18 billion possible codes. Collisions are astronomically unlikely in any real corpus. However, the validator checks for duplicates within a collection and warns if found.
Deterministic generation: When possible, generate hex codes from a hash of the source URL. This means the same source always gets the same hex code, even if cited in different documents:
function generateHexCode(url: string): string {
const hash = crypto.createHash('md5').update(url).digest('hex');
return hash.substring(0, 6);
}
17.5 Image Paths
Images in markdown content can reference:
| Path Type | Example | Resolution |
|---|---|---|
| Absolute (public) | /images/hero.jpg | Served from site’s public/ directory |
| Relative | ./assets/diagram.png | Relative to the markdown file’s location |
| Remote | https://example.com/img.jpg | Fetched at build time (optional) or referenced directly |
| Astro import | Not supported | Use directive or standard syntax instead |
Gotcha: Content that lives in a package (node_modules/@lossless/content/...) cannot use relative image paths that reference files outside the package. Images should be in public/ or use absolute URLs.
17.6 Nested Blockquotes and Obsidian Callouts
Obsidian callout syntax uses blockquote prefix (>). This means you can’t easily nest a regular blockquote inside an Obsidian callout:
> [!info] Note
> This is the callout content.
> > This is a nested blockquote inside the callout — ambiguous!
Rule: We recommend using directive syntax for callouts when nesting is needed:
:::callout{type="info" title="Note"}
This is the callout content.
> This is a blockquote inside the callout — unambiguous.
:::
18. Domain-Specific Extensions (Investment / VC)
Beyond general-purpose markdown features, we define domain-specific directives for the investment memo and VC use case. These are registered as custom directives per-site, not part of the core shared package.
18.1 Investment Content Directives
Investment Thesis Block
:::investment-thesis
Enzyme-based metabolic interventions represent a $50B+ market opportunity.
The company's proprietary enzyme design platform offers 10x cost advantage
over GLP-1 drugs with no prescription requirement, creating a massive
consumer health opportunity that incumbents cannot easily replicate.
:::
Rendering: Distinctive visual treatment — colored left border, larger type, optional icon. In memos, this is the “elevator pitch” block. In PDFs, gets page-break avoidance.
Key Risk Block
:::key-risk{severity="high" category="regulatory"}
The FDA pathway for this specific enzyme combination is unproven.
No directly comparable product has received clearance, which means
regulatory timeline is uncertain and could extend by 12-18 months.
:::
Rendering: Severity-colored indicator (green/yellow/orange/red for low/medium/high/critical). Category tag shown as a badge. In the one-pager, high/critical risks get flagged automatically.
Severity levels:
| Severity | Color | Meaning |
|---|---|---|
low | Green | Manageable, standard for the space |
medium | Yellow | Notable, needs monitoring |
high | Orange | Significant, could affect thesis |
critical | Red | Deal-breaker potential, must be addressed |
Risk categories: regulatory, market, technology, team, financial, competitive, legal, operational
Data Point Block
:::data-point{source="Goldman Sachs" date="2026-01" confidence="verified"}
The GLP-1 market is projected to reach $100B by 2030, up from $24B in 2025.
:::
Rendering: Styled attribution block with source, date, and confidence indicator. Useful for key statistics that need clear provenance.
Confidence levels: verified (human-confirmed), ai-sourced (AI found it, not yet verified), estimated (rough figure), projected (forward-looking)
Comparable Company Card
::comparable{name="Enzymedica" funding="Private" stage="Growth" relevance="primary"}
Rendering: Compact card with company name, funding status, stage, and relevance level. Used in competitive landscape sections. Can be grouped in a :::comparable-grid.
Scorecard Item
:::scorecard-item{dimension="Market Size" score="4" max="5"}
The addressable market exceeds $50B and is growing at 15%+ annually.
Strong demographic tailwinds (aging population, metabolic health crisis)
provide durable demand regardless of economic cycles.
:::
Rendering: Score visualization (filled/empty dots or bar), dimension label, and supporting text. Used for the 12Ps scorecard or similar evaluation frameworks.
18.2 Investment Memo Template Sections
These aren’t directives — they’re frontmatter-driven section types that influence layout:
---
title: "Metabologic: Investment Memo"
type: investment-memo
sections:
- overview
- why-invest
- market-overview
- team
- business-economics
- fundraising-round
- flags
memo_version: "v0.2.5"
firm: "Humain Ventures"
deal: "Metabologic"
---
The type: investment-memo triggers a memo-specific layout with:
- Firm branding in header/footer
- Section numbering
- One-pager summary generation
- PDF export with branded cover page
19. Content Portability
A core principle: content written in Lossless Flavored Markdown should be maximally portable. You should be able to move a .md file between sites, between repos, and between tools with predictable results.
19.1 Portability Tiers
| Destination | What Works | What Degrades | What Breaks |
|---|---|---|---|
| Another LFM site | Everything | Nothing | Nothing |
| Obsidian | CommonMark, GFM, callouts (Obsidian syntax), wikilinks | Directives show as raw text | Nothing breaks outright |
| VS Code preview | CommonMark, GFM, code blocks | Everything else shows as raw text | Nothing breaks |
| GitHub rendering | CommonMark, GFM, Mermaid, math (partial) | Directives, citations, callouts | Nothing breaks |
| Notion import | CommonMark basics | Most extended features lost | Complex tables may break |
| Pandoc conversion | CommonMark, GFM, math | Directives need custom filter | Nothing breaks with --from gfm |
| Plain text (email, etc.) | Readable prose | All formatting lost | Nothing breaks |
19.2 Export Formats
The shared package should support exporting LFM content to:
| Format | Method | Fidelity |
|---|---|---|
| HTML | rehype-stringify (built-in) | Full |
| HTML → window.print() / Puppeteer | Full minus interactivity | |
| DOCX | HTML → pandoc → docx | High (tables, headings, images, callouts as styled blocks) |
| Plain Markdown | Strip directives, flatten citations | Lossy but readable |
| Obsidian-compatible | Convert directives to Obsidian callouts, keep wikilinks | High for common features |
| Slide deck | Extract slide-separated content → reveal.js | Full for slide content |
19.3 Import from Other Formats
Content coming INTO our system:
| Source | Strategy |
|---|---|
| Obsidian vault | Callouts already compatible; wikilinks need collection-scoped resolution; embeds need path rewriting |
| Notion export | Markdown is basic; needs frontmatter addition; links need rewriting |
| Google Docs | Export as HTML → Pandoc → Markdown → add frontmatter |
| Confluence | Export as HTML → Pandoc → Markdown → significant cleanup needed |
| Word documents | Pandoc → Markdown → add frontmatter and directives |
20. Link Handling Deep Dive
Links are deceptively complex. Different link types need different handling.
20.1 Link Types
| Type | Example | Behavior |
|---|---|---|
| External | [text](https://example.com) | Opens in new tab, rel="noopener noreferrer", external icon |
| Internal (relative) | [text](./other-page) | Standard navigation, same tab |
| Internal (absolute) | [text](/blueprints/my-doc) | Standard navigation, same tab |
| Anchor | [text](#section-heading) | Smooth scroll to anchor |
| Wikilink | [[Page Title]] | Resolved at build time to internal URL |
| Auto-detected URL | https://example.com | Converted to clickable link (GFM autolink) |
mailto:user@example.com | Opens mail client |
20.2 Smart Link Embeds (Wish List)
When a URL is on its own line (not inline in a paragraph), the renderer can optionally “unfurl” it into a rich preview card:
Here is a relevant article:
https://www.nytimes.com/2026/03/15/health/metabolic-enzymes.html
Rendering: OpenGraph card with title, description, and thumbnail (fetched at build time). Falls back to a plain link if OG data is unavailable.
Opt-out: Prefix with \ to prevent unfurling:
\https://www.nytimes.com/2026/03/15/health/metabolic-enzymes.html
20.3 Broken Link Detection
At build time, the validator can check:
- Internal links: Does the target page exist in any content collection?
- Anchor links: Does the target heading ID exist in the current document?
- Wikilinks: Does the target title match any document?
- External links (optional, slow): Does the URL return a 200 status?
WARNING: Broken internal link at line 42: /blueprints/old-page (not found)
WARNING: Broken anchor at line 67: #nonexistent-heading
WARNING: Unresolved wikilink at line 89: [[Page That Does Not Exist]]
INFO: External link check: 3/47 URLs returned non-200 status
21. Image Handling Deep Dive
21.1 Image Sources
| Source | Syntax | Build Behavior |
|---|---|---|
public/ directory |  | Copied as-is to output |
src/assets/ | Not supported in markdown (use directive) | Would need Astro import |
| Remote URL |  | Referenced directly (no download) |
| Content-relative |  | Resolved relative to markdown file |
21.2 Responsive Images (Wish List)
The image directive should support responsive art direction:
::image{
src="/images/chart.png"
srcset="/images/chart-400.png 400w, /images/chart-800.png 800w, /images/chart-1200.png 1200w"
sizes="(max-width: 600px) 400px, (max-width: 1000px) 800px, 1200px"
alt="Market growth chart"
caption="Source: Goldman Sachs Research, 2026"
}
21.3 Image Optimization
When using Astro’s image optimization (Sharp), images referenced from src/assets/ get automatic optimization. But content images in public/ or from remote URLs do not.
Proposed solution: A build-time image optimization pass that:
- Scans all markdown content for image references
- Downloads remote images to a local cache
- Generates responsive variants (WebP, AVIF)
- Rewrites image references to optimized versions
This is a Phase 4+ feature and should be opt-in per collection.
22. Performance Considerations
22.1 Plugin Cost
Not all remark/rehype plugins are equal in cost:
| Plugin | Cost | Notes |
|---|---|---|
remarkParse | Baseline | Always needed |
remarkGfm | Low | Well-optimized |
remarkDirective | Low | Simple syntax extension |
remarkCitations | Medium | Two-pass (collect definitions, then resolve references) |
remarkBacklinks | High | Requires cross-document index; must process all docs first |
remarkToc | Low | Single-pass heading extraction |
rehypeShiki | High | Syntax highlighting is CPU-intensive; cache aggressively |
rehypeMermaidPre | Low | Simple node transformation |
| External link checking | Very High | Network requests; must be opt-in and cached |
22.2 Build-Time Optimization Strategies
- Shiki caching: Cache highlighted code block HTML by content hash. If the code + language hasn’t changed, reuse the cached output
- Parallel processing: Process independent documents in parallel (Astro does this for content collections)
- Lazy backlinks: Build the cross-document index once, then look up per-document (don’t re-scan all docs for each page)
- Skip validation in production: Run full validation in dev/CI, skip in production builds for speed
- Mermaid SSR (future): Render Mermaid diagrams at build time instead of client-side to reduce JS payload
22.3 Client-Side Performance
Features that add client-side JavaScript:
| Feature | JS Added | Loading Strategy |
|---|---|---|
| Copy button on code blocks | ~1KB | Inline script, no framework |
| Mermaid diagrams | ~800KB | CDN, lazy-loaded when in viewport |
| Citation popovers | ~2KB | Inline script, event delegation |
| Tabs | ~1KB | Inline script, no framework |
| JSON Canvas | ~50KB+ | Lazy-loaded, only when canvas is in viewport |
| Table sorting | ~3KB | Lazy-loaded, only for tables with sortable attribute |
| Lightbox | ~15KB | Lazy-loaded, only when gallery images are clicked |
Budget: Total JavaScript from markdown features should not exceed 5KB eagerly loaded. Everything else must be lazy.
23. Content Linting Rules
Beyond syntax validation, the shared package defines content quality rules that can be enforced at build time or in a pre-commit hook.
23.1 Structural Rules
RULE: frontmatter-required
Every .md file must have YAML frontmatter with at least `title` and `date_created`
RULE: heading-hierarchy
Heading levels must not skip (H2 → H4 without H3)
RULE: single-h1
A document should have at most one H1 heading (the title)
RULE: code-block-language
Every fenced code block must specify a language (no bare ```)
RULE: image-alt-text
Every image must have non-empty alt text
RULE: citation-completeness
Every [^hexcode] reference must have a corresponding definition
RULE: no-bare-urls-in-prose
URLs in paragraph text should be wrapped in link syntax, not bare
23.2 Style Rules (Optional)
RULE: max-heading-length
Headings should be under 80 characters
RULE: no-trailing-whitespace
Lines should not end with trailing spaces
RULE: consistent-list-markers
Use either `-` or `*` for unordered lists, not both
RULE: blank-line-around-blocks
Directives, code blocks, and tables should have blank lines before and after
RULE: no-html
Prefer directives over raw HTML (warning, not error)
23.3 Domain Rules (Investment Memos)
RULE: citation-density
Each section should have at least 2 citations per 500 words
RULE: risk-coverage
A memo must include at least one :::key-risk block
RULE: thesis-required
A memo must include an :::investment-thesis block
RULE: source-freshness
Citation published dates should be within 2 years of the memo date
24. Migration Guide
For sites currently using ad-hoc remark plugin copies, here’s the migration path to the shared package.
24.1 Audit Current Plugins
Run this across your site to find all remark/rehype usage:
# Find all remark/rehype plugin imports
grep -r "from.*remark-\|from.*rehype-\|\.use(remark\|\.use(rehype" src/ --include="*.ts" --include="*.mjs" --include="*.astro"
# Find all custom remark plugins
find src/ -name "remark-*.ts" -o -name "rehype-*.ts"
24.2 Compare Against Shared Package
For each plugin found:
| Your Plugin | Shared Package Equivalent | Action |
|---|---|---|
src/utils/remark-directives.ts | @lossless-group/lfm/remark/directives | Replace, migrate custom directive names to customDirectives config |
src/utils/remark-citations.ts | @lossless-group/lfm/remark/citations | Replace |
src/utils/remark-toc.ts | @lossless-group/lfm/remark/toc | Replace |
src/utils/remark-custom-feature.ts | (none) | Keep as site-specific plugin, load after the preset |
24.3 Wire Up the Preset
Before (ad-hoc):
import remarkGfm from 'remark-gfm';
import remarkDirective from 'remark-directive';
import { remarkDirectiveToComponent } from '../utils/remark-directives';
import { remarkCitations } from '../utils/remark-citations';
import { remarkToc } from '../utils/remark-toc';
const processor = unified()
.use(remarkParse)
.use(remarkGfm)
.use(remarkDirective)
.use(remarkDirectiveToComponent)
.use(remarkCitations)
.use(remarkToc);
After (shared package):
import { remarkLfm } from '@lossless-group/lfm';
const processor = unified()
.use(remarkParse)
.use(remarkLfm, {
citations: true,
toc: { minDepth: 2, maxDepth: 4 },
customDirectives: [
// any site-specific directives
],
});
24.4 Verify Rendering Parity
After migration, compare rendered output:
- Build the site with the old plugins, save the HTML output
- Switch to the shared package, rebuild
- Diff the HTML output — any differences are regressions to investigate
# Quick smoke test
diff <(curl -s http://localhost:4321/blueprints/test-page) old-output.html
25. VS Code Authoring Support
Authors writing Lossless Flavored Markdown in VS Code should have a good experience even without our rendering pipeline.
25.1 Recommended Extensions
| Extension | Purpose |
|---|---|
| Markdown All in One | TOC generation, list editing, math preview |
| markdownlint | Enforce structural lint rules |
| Markdown Preview Mermaid | Mermaid diagrams in VS Code preview |
| YAML (Red Hat) | Frontmatter validation and autocomplete |
25.2 Snippet Library (Proposed)
A VS Code snippets file (.vscode/lfm.code-snippets) shipped with the shared package:
{
"LFM: Callout": {
"prefix": "callout",
"body": [
":::callout{type=\"${1|info,tip,warning,danger,note,success,quote,example|}\" title=\"${2:Title}\"}",
"$0",
":::"
],
"description": "Insert a callout/admonition block"
},
"LFM: Details": {
"prefix": "details",
"body": [
":::details{title=\"${1:Click to expand}\"}",
"$0",
":::"
],
"description": "Insert a collapsible details block"
},
"LFM: Badge": {
"prefix": "badge",
"body": ":badge[${1:Label}]{variant=\"${2|default,success,warning,danger,date,version|}\"}",
"description": "Insert an inline badge"
},
"LFM: Citation": {
"prefix": "cite",
"body": [
"[^${1:hexcode}]",
"",
"[^${1:hexcode}]: ${2:YYYY}. [${3:Title}](${4:URL}). Published: ${5:YYYY-MM-DD}"
],
"description": "Insert a hex-code citation with definition"
},
"LFM: YouTube Embed": {
"prefix": "youtube",
"body": "::youtube{id=\"${1:video-id}\"}",
"description": "Embed a YouTube video"
},
"LFM: Image Gallery": {
"prefix": "gallery",
"body": [
":::image-gallery{columns=\"${1:3}\"}",
"",
"",
"",
":::"
],
"description": "Insert an image gallery"
},
"LFM: Columns": {
"prefix": "columns",
"body": [
"::::columns{count=\"${1:2}\"}",
":::column",
"${2:Left content}",
":::",
"",
":::column",
"${3:Right content}",
":::",
"::::"
],
"description": "Insert a multi-column layout"
},
"LFM: Frontmatter": {
"prefix": "front",
"body": [
"---",
"title: ${1:Document Title}",
"lede: ${2:One-line summary}",
"date_created: ${CURRENT_YEAR}-${CURRENT_MONTH}-${CURRENT_DATE}",
"date_modified: ${CURRENT_YEAR}-${CURRENT_MONTH}-${CURRENT_DATE}",
"status: Draft",
"authors:",
" - ${3:Michael Staton}",
"tags: [${4:tag1, tag2}]",
"---",
"",
"# ${1:Document Title}",
"",
"$0"
],
"description": "Insert LFM frontmatter template"
},
"LFM: Investment Thesis": {
"prefix": "thesis",
"body": [
":::investment-thesis",
"$0",
":::"
],
"description": "Insert an investment thesis block"
},
"LFM: Key Risk": {
"prefix": "risk",
"body": [
":::key-risk{severity=\"${1|low,medium,high,critical|}\" category=\"${2|regulatory,market,technology,team,financial,competitive,legal,operational|}\"}",
"$0",
":::"
],
"description": "Insert a key risk block"
},
"LFM: Tabs": {
"prefix": "tabs",
"body": [
"::::tabs",
":::tab{label=\"${1:Tab 1}\"}",
"${2:Content 1}",
":::",
"",
":::tab{label=\"${3:Tab 2}\"}",
"${4:Content 2}",
":::",
"::::"
],
"description": "Insert a tabbed content block"
},
"LFM: Steps": {
"prefix": "steps",
"body": [
":::steps",
"### ${1:Step 1}",
"",
"${2:Instructions}",
"",
"### ${3:Step 2}",
"",
"${4:Instructions}",
":::"
],
"description": "Insert a numbered steps block"
},
"LFM: Data Point": {
"prefix": "datapoint",
"body": [
":::data-point{source=\"${1:Source Name}\" date=\"${2:YYYY-MM}\" confidence=\"${3|verified,ai-sourced,estimated,projected|}\"}",
"$0",
":::"
],
"description": "Insert a sourced data point block"
}
}
25.3 markdownlint Configuration
A .markdownlint.jsonc that works with our flavor:
{
// Allow multiple H1s (we use H1 in content, not just title)
"MD025": false,
// Allow trailing punctuation in headings (common in specs)
"MD026": { "punctuation": ".,;:!" },
// Allow HTML (we use it sparingly)
"MD033": false,
// Allow bare URLs (GFM autolinks)
"MD034": false,
// Disable line length (content wraps in the renderer)
"MD013": false,
// Allow non-blank lines around fences (needed for directive nesting)
"MD031": false,
// Allow emphasis as heading (we use it in timelines)
"MD036": false
}
26. Worked Example: A Complete Investment Memo Section
This shows how multiple features compose in a real document:
---
title: "Metabologic: Market Overview"
date_created: 2026-03-25
tags: [market-analysis, metabolic-health, GLP-1]
authors:
- AI Labs Team
augmented_with: "Claude + Perplexity Sonar Pro"
status: Review
at_semantic_version: 0.2.5
---
# Market Overview
## The Metabolic Health Crisis
Global metabolic disease prevalence is accelerating. Over 2.1 billion people
worldwide will be aged 60 or older by 2050,[^1ucdcd] driving demand for
accessible, affordable metabolic health interventions.
:::callout{type="info" title="Market Size Context"}
The total addressable market for metabolic health interventions exceeds
**$150B annually** when combining pharmaceuticals, supplements, digital
therapeutics, and clinical services.
:::
:::data-point{source="Goldman Sachs" date="2026-01" confidence="verified"}
The GLP-1 receptor agonist market alone is projected to reach $100B by 2030,
up from $24B in 2025 — a 33% CAGR.[^k9m6ww]
:::
## GLP-1 Drugs vs. Enzyme-Based Approaches
The dominant pharmaceutical approach to metabolic health — GLP-1 receptor
agonists like :tooltip[Ozempic]{content="Semaglutide, manufactured by Novo
Nordisk. Approved for Type 2 diabetes and obesity."} and
:tooltip[Mounjaro]{content="Tirzepatide, manufactured by Eli Lilly. Dual
GIP/GLP-1 agonist approved for Type 2 diabetes and obesity."} — faces
critical limitations:
| Factor | GLP-1 Drugs | Enzyme Approach (Metabolic) |
|--------|------------|---------------------------|
| Cost/month | $1,000–$1,300 | $60–$120 |
| Prescription | Required | Not required |
| Delivery | Injection | Oral supplement |
| Side effects | Nausea, gastroparesis, muscle loss | Minimal (digestive) |
| Discontinuation | ~80% within 2 years[^d7f2x3] | TBD (early data promising) |
| Insurance coverage | Inconsistent | N/A (consumer product) |
:::key-risk{severity="medium" category="market"}
GLP-1 drug prices may decrease significantly as patents expire and biosimilars
enter the market (earliest: 2031). This could narrow the cost advantage of
enzyme-based approaches, though the prescription and injection barriers
would remain.
:::
## Competitive Landscape
```mermaid
quadrantChart
title Competitive Positioning
x-axis Low Cost --> High Cost
y-axis Low Efficacy --> High Efficacy
quadrant-1 Premium Pharma
quadrant-2 Sweet Spot
quadrant-3 Commodity
quadrant-4 Overpriced
Ozempic/Wegovy: [0.85, 0.80]
Mounjaro: [0.80, 0.85]
Metabologic: [0.25, 0.55]
Enzymedica: [0.20, 0.30]
FODZYME: [0.30, 0.35]
Twin Health: [0.50, 0.50]
```
:::details{title="Full Competitor List (19 companies analyzed)"}
The competitive evaluation spans 19 companies across four categories:
- **Primary competitors** (3): Enzymedica, FODZYME, Holozyme
- **Direct competitors** (5): Including biosimilar manufacturers
- **Indirect competitors** (7): Digital therapeutics, coaching platforms
- **Loose comparables** (4): GLP-1 manufacturers (different category, useful for market context)
See the full competitive evaluation in the deal's `competitive-curation.json`.
:::
## Investment Thesis
:::investment-thesis
Metabologic is positioned to capture a significant share of the emerging
"GLP-1 alternative" market by offering an enzyme-based oral supplement at
1/10th the cost of injectable GLP-1 drugs, with no prescription requirement.
The company's proprietary enzyme design platform — validated in 3 peer-reviewed
studies — creates a defensible moat that commodity supplement brands cannot
replicate. With the GLP-1 market projected at $100B by 2030 and
discontinuation rates exceeding 80%, there is a massive underserved population
seeking affordable, sustainable metabolic health solutions.
:::
---
## Sources
[^1ucdcd]: 2025, Sep 21. [Population ageing: Navigating the demographic shift](https://www.helpage.org/news/population-ageing-navigating-the-demographic-shift/). Published: 2024-07-11
[^k9m6ww]: 2026, Jan 15. [GLP-1 Market Outlook 2030](https://www.goldmansachs.com/insights/glp1-market-2030). Published: 2026-01-15
[^d7f2x3]: 2025, Nov 08. [Real-world GLP-1 discontinuation rates](https://www.nejm.org/doi/full/10.1056/NEJMoa2503142). Published: 2025-11-08
This example demonstrates: frontmatter, hex-code citations, callouts, data points, tooltips, tables, Mermaid diagrams, key risks, details blocks, investment thesis blocks, and proper source definitions — all composing naturally in a single document.
27. Implementation Reference: Link Substitution Components
Self-contained handoff for implementing §4.23.6 (Inline Link Substitutions) plus the related Phase-0 foundation. Written so a fresh session can pick this up cold without re-reading the rest of the spec.
27.1 What’s already shipped
| Capability | File pointer | Notes |
|---|---|---|
| Bare-URL classifier (render-time) | sites/mpstaton-site/src/lib/markdown/classify-bare-link.ts | Pure TS, mirrors the canonical catalog. Use as the model for the inline-link classifier. |
| Bare-link provider catalog (canonical record) | packages/lfm/src/plugins/Bare-Link-Provider-Catalog.md | Frontmatter is the source of truth. Already covers YouTube watch / short / playlist + Vimeo as stable; Loom / Spotify / SoundCloud as planned. |
| Bare-link → component dispatch | sites/mpstaton-site/src/components/markdown/AstroMarkdown.astro (paragraph branch) | Reference for how to add the inline-link directive dispatch. |
FullPlayer components | sites/mpstaton-site/src/components/markdown/{YouTubeEmbed,YouTubeShortsEmbed,YouTubePlaylistEmbed,VimeoEmbed}.astro | These are the LinkPreview__Video--FullPlayer flavor under the new taxonomy. Don’t rename existing files — alias the names in component dispatch when needed. |
| OG popover infrastructure (specced, not built) | This spec, §4.23.5 | The single-global-popover pattern + build-time OG fetch are documented; remark-bare-link plus the OG fetcher remain to be built. |
27.2 Build order (dependencies enforced)
Phase 0 ─┬─ OG fetcher (build-time, cached)
├─ LinkPreviewData type in @lossless-group/lfm
└─ Inline-link classifier (mirror of bare-link classifier)
│
▼
Phase 1 ─┬─ ::: link-preview directive transform
├─ LinkPreview__Article--Row.astro
├─ LinkPreview__Article--Card.astro
├─ LinkPreview__Article--Thumb.astro
└─ LinkPreview__Article--LiveSite.astro
│
▼
Phase 2 ─┬─ LinkPreview__Video--Row.astro
├─ LinkPreview__Video--Card.astro
├─ LinkPreview__Video--Thumb.astro
└─ LinkPreview__Video--Rollup-Gallery.astro
│
▼
Phase 3 ─┬─ ::: link-rollup directive transform
├─ LinkRollup__Column.astro
├─ LinkRollup__Gallery.astro
├─ LinkRollup__Carousel.astro
└─ LinkRollup__ThumbRow--HorizontalScroll.astro
Each component lands in sites/mpstaton-site/src/components/markdown/ first (per the “build in client sites first, extract later” motion). When the design is stable, copy into packages/lfm-astro/components/ as the canonical pattern source for sibling sites.
27.3 Files to read first when starting
In order:
- §4.23.6 of this spec — the design contract (component naming, directive grammar, type/format taxonomies,
LinkPreviewDatashape). - §4.23.5 — the OG-fetch + popover infrastructure that backs both this and the popover family.
packages/lfm/src/plugins/Bare-Link-Provider-Catalog.md— provider entries are the canonical match list; reuse for video metadata.sites/mpstaton-site/src/lib/markdown/classify-bare-link.ts— model for the inline-link classifier. Pattern: pure function, named-capture extraction, mirrors the catalog.sites/mpstaton-site/src/components/markdown/VimeoEmbed.astro— reference component shape (semantic-token theming, hex fallbacks, optional caption + copy-URL button).
27.4 Architectural decisions already made
- OG fetcher lives in LFM, not per-site. One canonical fetcher with one cache shape. Sites import it; cache file lands in each site under
src/data/og-cache.json(gitignored). - Cache key = URL hash, not URL string. Avoid filesystem-unsafe characters. Use SHA-256 truncated to 16 hex chars.
- Failed fetches are cached as failures (4xx, timeout, no OG tags) and retried after 24h. Don’t block the build on a slow third party.
- Tier-A (bare URL) and Tier-B (
:::link-preview) directives share directive names per provider. When a YouTube URL is bare and unfurls to::youtube-video, the explicit Tier-B form is also::youtube-video{...}. The new:::link-preview{type=video format=row}is a different directive — substitution preview, not full embed. LinkRollup__*containers render their children as the matching format of the preview type. AGalleryrollup ofArticleURLs renders each child asLinkPreview__Article--Cardbecause Gallery := Card grid. The rollup component owns the layout primitive; the preview type owns the data shape.- Hover popovers (4.23.1-4) and substitutions (4.23.6) are separate primitives sharing infrastructure. Don’t conflate. A page can use both on the same URL (popover by default; substitution per-instance via directive).
- Theme tokens are semantic, not brand-specific. Use
--card,--brand-aqua,--foreground,--borderwith hex fallbacks. Never bake--bastilleor--lossless-accent--brightestinto a copy-pattern component. - OG fetcher backend is pluggable; OpenGraph.io is the production default. Direct
fetch()fails on too many real-world URLs (Cloudflare, JS-rendered, anti-scrape). The plugin is a thin dispatcher over backend modules —direct,opengraph-io,proxy,frontmatter-only. New backends are added as sibling modules without plugin-API churn. Required env:OPENGRAPH_IO_KEY(production sites). See §4.23.6 Build-time OG fetch “Fetch backend — pluggable” for the table and config shape. - Code-fence form of LFM directives is mandatory for content portability. Authors edit in Obsidian and depend on community plugins that emit code fences with the directive’s name as the lang.
```link-previewand:::link-previewMUST resolve to the samecontainerDirectiveMDAST node. Theremark-code-fence-as-directivetransform (forthcoming) handles this before Shiki, with the directive registry as the source of truth. See §4.23.6 Author syntax for grammar. LinkPreviewDatais a minimum-viable subset of the canonical Sources schema. Field names align with the canonical vocabulary (source→publisher,publishedAt→date_published, etc.) andcanonicalSource?: { uuid, hexcode?, slug? }provides a non-breaking promotion path. The full canonical pipeline (download, structured-data extraction, AI classification) lives incite-wideand is out of scope for the link-preview component work. Do not block on it.asideis a generic positioning attribute, not LinkPreview-specific. Implement the read inLinkPreview__*andLinkRollup__*components first, but use the stable class nameslfm-aside lfm-aside--{value}so any future container directive (callouts, images, data-points, citations) can adopt with a one-line addition.*-escapevalues align outside the main content column in the docs-style nav/TOC track — not as page-margin bleed. Conflict-resolution config (stackdefault,inline-fallback,replace-on-section) lives on the LFM preset, not per-component.- No-code extensibility is a three-layer system; implement all three from the start. (1) Reserved Layer-1 attributes on every component (
class,style,id,data-*,aria-*,lang) merged onto the wrapper. (2) Per-site variant registry atsrc/config/lfm-variants.yamlwithkind=<name>lookups. (3) CSS custom-property tokens for theme-wide visual overrides. Don’t ship a component without all three; missing any one creates a friction cliff that drives authors to ask AI to modify TS, defeating the purpose. Built-in component classes (lfm-link-preview, etc.) ALWAYS come first in the class list so site CSS cascade is predictable.
27.5 Things NOT to do
- ❌ Don’t ship without the OG fetcher. Components rendering “url goes here” cards isn’t a useful intermediate state.
- ❌ Don’t rename the existing
YouTubeEmbed.astro/VimeoEmbed.astrotoLinkPreview__Video--FullPlayer.astro. Alias them at the dispatch layer if the taxonomy requires it. Renaming breaks site git history and any imports that haven’t migrated. - ❌ Don’t put the OG cache in source control. It’s a per-site, regeneratable artifact. Add to
.gitignore. - ❌ Don’t fetch OG metadata at request time. Build-time only. The fetcher is allowed to be slow once per build; per-request fetches fail Vercel cold-start budgets.
- ❌ Don’t make
LinkPreview__*--LiveSitework without an explicittrustedflag. iframes of arbitrary URLs are an XSS / clickjacking risk. The default should refuse to render and emit a build warning. - ❌ Don’t try to make rollups support mixed types. One
LinkRollup__*container = one type. Mixed grids of Article + Video cards are a future feature with its own design. - ❌ Don’t try to solve the canonical Sources mapping inside this work. The cite-wide blueprint is still evolving (Academic / Market Analyst / Web Ready / Obsidian Style sub-sections empty). Stay inside the bridge defined in §4.23.6 —
canonicalSource?field, alignment table — and stop. The pipeline that populates canonical entries is its own track. - ❌ Don’t hardcode
directas the fetch backend. Even for early dev, default toopengraph-ioif a key is present in env; fall back todirectonly when no key is configured AND emit a build warning that production should pin a real backend. - ❌ Don’t render an
aside=*-escapecard without verifying the layout exposes the escape track (a CSS Grid declaring[content-start]/[content-end]lines or equivalent). On layouts that don’t expose it, escape values MUST collapse to inline flow — silently in production, with a build warning in dev. - ❌ Don’t hardcode the conflict-resolution policy in components. Read
asideResolutionfrom the LFM preset config; default tostackonly when the config is absent. The right resolution differs per site (a docs-heavy site wantsstack; a memo site that occasionally drops in*-escapecards wantsinline-fallback). - ❌ Don’t allow author-supplied attributes to overwrite component-managed ones.
format,type,aside,kind, and internaldata-lfm-*are filtered from Layer-1 input. Silent in production; build-warning in dev. An author setting:::link-preview{format=card type=video format=row}should land on the FIRSTformat=card, not on a Layer-1 override. - ❌ Don’t put the variant registry in TypeScript. Per
feedback_yaml_data_files: YAML for client-editable data, TypeScript for runtime logic. The registry is editable by content authors and designers; a typo should produce a build warning, not a build failure.
27.6 Verification per phase
Phase 0 done when:
pnpm buildin mpstaton-site fetches OG metadata for all observed URLs and writessrc/data/og-cache.json.- Re-running
pnpm buildis a no-op for the cache (100% hits). - A URL returning 404 is cached as a failure and doesn’t crash the build.
- The inline-link classifier correctly types a YouTube URL as
Videoand an arbitrary blog URL asArticle.
Phase 1 done when:
- Authoring
:::link-preview{format=card}\nhttps://example.com/article\n:::in a memo renders an OG-derived card. - The card degrades to a plain link if OG fetch failed.
- All four
Articleformat variants render correctly inmpstaton-site’s_democontent. - A
pnpm buildcycle with all four formats stays under the existing build budget (currently ~6s).
Phase 2 done when:
- Authoring
:::link-preview{type=video format=row}\nhttps://youtu.be/jCe2wg1ulus\n:::renders a video row with thumbnail + title. - The classifier reuses the bare-link catalog’s matchers; new providers don’t require duplicate code.
Phase 3 done when:
- Authoring
:::link-rollup{type=video format=gallery}with 6 video URLs renders a clean grid. - Carousel scrolls with arrow keys + buttons; gallery reflows responsively.
- The whole demo memo (
/promote/_demo/memo/version-1) showcases all formats end-to-end.
27.7 Changelog markers when each phase ships
Add a changelog entry to sites/mpstaton-site/src/content/changelog/{date}_NN.md for each phase, following the established style (title + body, mermaid diagrams welcome, demo URLs to test). Don’t bundle phases — separate commits per phase, separate JSR releases for any LFM-package additions.
27.8 Cross-references
- Spec §4.12 — bare-URL auto-unfurl (the FullPlayer path; complementary feature, shared catalog)
- Spec §4.23 — Smart Popovers and Link Previews (hover variant; shares OG infrastructure)
- Spec §4.23.5 — OG fetch architecture
- Spec §4.23.6 — design contract for this work
- Spec §6 — Shared Package Architecture (where the OG fetcher and inline-link classifier live)
- Catalog file —
packages/lfm/src/plugins/Bare-Link-Provider-Catalog.md - Existing FullPlayer reference implementations —
sites/mpstaton-site/src/components/markdown/{YouTube*,Vimeo*}.astro
28. Glossary
| Term | Definition |
|---|---|
| MDAST | Markdown Abstract Syntax Tree — the intermediate representation produced by remarkParse and transformed by remark plugins |
| Directive | An extension syntax (:::name{}) that maps to a component at render time |
| Container directive | A directive that wraps other markdown content (:::name ... :::) |
| Leaf directive | A self-closing directive with no children (::name{}) |
| Text directive | An inline directive within paragraph text (:name[content]{}) |
| Hex code | A 6-character alphanumeric identifier for citations (e.g., 1ucdcd) |
| Unified | The ecosystem of markdown/HTML processing tools (remark, rehype, etc.) |
| Remark | Markdown processor in the Unified ecosystem; operates on MDAST |
| Rehype | HTML processor in the Unified ecosystem; operates on HAST |
| HAST | HTML Abstract Syntax Tree — the intermediate representation for HTML |
| Shiki | Syntax highlighter that uses VS Code’s TextMate grammars |
| GFM | GitHub Flavored Markdown — CommonMark superset with tables, task lists, etc. |
| CommonMark | The standard markdown specification that serves as our baseline |
| LFM | Lossless Flavored Markdown — the name for this flavor, echoing GFM (GitHub Flavored Markdown) |
| Transclusion | Embedding one document’s content inside another at build time |
| Frontmatter | YAML metadata at the top of a markdown file, delimited by --- |
| Graceful degradation | The principle that unsupported features should render as readable text, not errors |
Changelog
| Date | Change |
|---|---|
| 2026-03-25 | Initial draft — codifying existing capabilities and wish list |
| 2026-03-25 | Major expansion — added directive deep dive, print/PDF behavior, accessibility, dark/light/vibrant mode, edge cases, domain-specific extensions, content portability, link handling, image handling, performance considerations, content linting, migration guide, VS Code support, worked example, and glossary |
| 2026-05-03 | §4.12 updated to point at the canonical bare-link catalog and component names; §4.23.6 added (Inline Link Substitutions — LinkPreview__* and LinkRollup__* taxonomy + directive grammar + LinkPreviewData shape); refined #### Next workflow with phased build order; added §27 Implementation Reference for next-session pickup. |
| 2026-05-03 | §4.23.6 extended in response to author feedback: (1) LinkPreviewData field-aligned with the canonical Sources schema in cite-wide and canonicalSource?: { uuid, hexcode?, slug? } added as a non-breaking promotion bridge; (2) code-fence form of directives (```link-preview) made mandatory for Obsidian content portability via remark-code-fence-as-directive; (3) OG fetcher backends made pluggable with OpenGraph.io as the production default. §27 architectural-decisions and “things not to do” lists updated to match. |
| 2026-05-03 | §4.23.6 extended again with the aside positioning attribute (none | left | right | left-escape | right-escape). *-escape values align components outside the main content column in the same docs-style nav/TOC track, with explicit conflict-resolution policy (stack default | inline-fallback | replace-on-section) for the future where a TOC / file-navigator / page-outline component shares the track. Stable lfm-aside lfm-aside--{value} class contract. Compatibility matrix per component family. Cross-cutting: any container directive can adopt the attribute. §27 decisions / anti-patterns updated. |
| 2026-05-03 | §4.23.6 extended with the no-code extensibility scheme. Three layers: Layer 1 — reserved per-instance attributes (class, style, id, data-*, aria-*, lang) on every directive, merged onto the wrapper. Layer 2 — per-site variant registry at src/config/lfm-variants.yaml with kind=<name> resolving to bundles of classes + attrs, schema portable across all astro-knots sites. Layer 3 — CSS custom-property tokens in packages/lfm-astro/css/tokens.css for theme-wide visual override. Reserved attribute names (format, type, aside, kind, internal data-lfm-*) are component-managed. Validation knob strictAttributes: 'warn' | 'error' | 'silent'. §27 decisions / anti-patterns updated. |