perf(site): migrate markdown pipeline to Sätteri (#1733)

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Darius Cepulis
2026-07-02 10:10:35 -07:00
committed by GitHub
co-authored by Claude
parent 47e74a139e
commit fb50e331f2
24 changed files with 540 additions and 468 deletions
+6 -9
View File
@@ -465,9 +465,9 @@ importers:
site:
dependencies:
'@astrojs/markdown-remark':
specifier: ^7.2.0
version: 7.2.0
'@astrojs/markdown-satteri':
specifier: ^0.3.1
version: 0.3.1
'@astrojs/mdx':
specifier: ^7.0.0
version: 7.0.0(@astrojs/markdown-satteri@0.3.1)(astro@7.0.0(@astrojs/markdown-remark@7.2.0)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@netlify/blobs@10.7.9)(@types/node@24.12.2)(jiti@2.7.0)(rollup@4.59.0)(tsx@4.21.0)(yaml@2.9.0))
@@ -549,9 +549,6 @@ importers:
marked:
specifier: ^17.0.1
version: 17.0.4
mdast-util-to-string:
specifier: ^4.0.0
version: 4.0.0
nanostores:
specifier: ^1.0.1
version: 1.1.1
@@ -567,6 +564,9 @@ importers:
satori:
specifier: ^0.26.0
version: 0.26.0
satteri:
specifier: ^0.9.1
version: 0.9.1
schema-dts:
specifier: ^1.1.5
version: 1.1.5
@@ -585,9 +585,6 @@ importers:
tailwindcss:
specifier: ^4.3.1
version: 4.3.1
unist-util-visit:
specifier: ^5.0.0
version: 5.1.0
vite:
specifier: ^8.0.0
version: 8.0.16(@types/node@24.12.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0)
+35 -21
View File
@@ -581,7 +581,7 @@ vi.mock('@/types/docs', async () => {
## Technology Stack
- **[Astro 7](https://astro.build)**: Static site generation with island architecture (Rust compiler; Markdown stays on the `unified()` remark/rehype processor — see "MDX Processing Plugins" below)
- **[Astro 7](https://astro.build)**: Static site generation with island architecture (Rust compiler; Markdown runs on the native **Sätteri** processor with custom MDAST plugins — see "MDX Processing Plugins" below)
- **[Vite 8](https://vite.dev)**: Underlying dev server and bundler, via Rolldown (see "Dependency Optimization" gotcha below)
- **[React 19](https://react.dev)**: Client-side interactive components (`client:load`)
- **[React Compiler](https://react.dev/learn/react-compiler)**: Enabled via `babel-plugin-react-compiler` targeting React 19
@@ -696,31 +696,45 @@ OAuth and Mux integration exist to support the **video uploader** on the install
## MDX Processing Plugins
Four plugins transform MDX content during build. Registered in `astro.config.mjs`.
The Markdown pipeline uses **Sätteri** (`markdown.processor: satteri({ mdastPlugins })`
in `astro.config.mjs`), Astro 7's native Rust processor. Custom transforms are
Sätteri **MDAST plugins** (`defineMdastPlugin` from `satteri`), not remark/rehype.
> **Markdown processor:** Astro 7 makes Sätteri the default Markdown pipeline,
> which does **not** run remark/rehype plugins. The site stays on the unified
> pipeline by passing the plugins to `markdown.processor: unified({ remarkPlugins,
> rehypePlugins })` (from `@astrojs/markdown-remark`), rather than the deprecated
> top-level `markdown.remarkPlugins`/`rehypePlugins` options. `syntaxHighlight`
> and `shikiConfig` remain top-level `markdown` options. Porting these plugins to
> Sätteri MDAST/HAST for build speed is tracked separately (issue #1719).
> **Syntax highlighting is independent of the processor.** `markdown.syntaxHighlight`
> and `shikiConfig` (themes, pre-registered langs, and `transformers`) are applied by
> Astro's Shiki layer regardless of processor, so the notation transformers carry over
> unchanged. GFM and SmartyPants stay on by default (Astro's `markdown.gfm`/`smartypants`
> default true), matching the previous output.
>
> **Plugins write frontmatter via `ctx.data.astro.frontmatter.*`.** The markdown-satteri
> adapter seeds the document data bag with `{ astro: { frontmatter, headings } }` and
> surfaces `data.astro.frontmatter` back as `render().remarkPluginFrontmatter` — the same
> contract the old remark plugins used via `file.data.astro.frontmatter`. Stateful plugins
> are written as factories (`() => defineMdastPlugin(...)`) so per-document state resets.
**`remarkConditionalHeadings`** (`src/utils/remarkConditionalHeadings.js`)
Walks the MDX AST and tracks headings inside `<FrameworkCase>` / `<StyleCase>` components, attaching conditional metadata (which frameworks/styles a heading belongs to). Also reads `<ComponentReference>` and `<UtilReference>` component props, loads the generated JSON, and injects heading entries so API reference sections appear in the table of contents. Outputs to `frontmatter.conditionalHeadings`.
**`satteriConditionalHeadings`** (`src/utils/satteriConditionalHeadings.ts`)
Collects headings (slugged with GithubSlugger in document order, so slugs match the ids the
markdown-satteri `heading-ids` plugin emits), tracking which `<FrameworkCase>` / `<StyleCase>`
each lives in via `ctx.parent()`. Reads `<ComponentReference>` / `<FeatureReference>` /
`<UtilReference>` / `<MediaReference>` props, loads the generated JSON, and injects heading
entries so API-reference sections appear in the TOC. Outputs `frontmatter.conditionalHeadings`.
**`remarkReadingTime`** (`src/utils/remarkReadingTime.mjs`)
Calculates reading time and injects `frontmatter.minutesRead` (text) and `frontmatter.readingTimeMinutes` (number).
**`satteriReadingTime`** (`src/utils/satteriReadingTime.ts`)
Accumulates text/code node content and injects `frontmatter.minutesRead` (text) and
`frontmatter.readingTimeMinutes` (number).
**`rehypePrepareCodeBlocks`** (`src/utils/rehypePrepareCodeBlocks.js`)
Tags `<code>` children of `<pre>` with a `codeBlock` property, and marks `<pre>` blocks with `hasFrame: true` when inside a `<TabsPanel>` JSX component. This controls code block styling (framed vs. standalone).
**`satteriCodeFrame`** (`src/utils/satteriCodeFrame.ts`)
Wraps standalone fenced code blocks in a `<CodeFrame>` component (filename/lang header + copy
button, reusing the `Tabs` chrome). Blocks already inside an authored `<TabsPanel>` are left
alone. The title is read from the fence meta (e.g. ```` ```ts title="App.ts"````), which is why
no Shiki title transformer is needed.
**`shikiTransformMetadata`** (`src/utils/shikiTransformMetadata.js`)
Shiki transformer that extracts `title="..."` from code fence metadata, enabling titled code blocks:
~~~markdown
```tsx title="Example.tsx"
~~~
> **Why `CodeFrame` instead of a `pre`/`code` component override:** under Sätteri the Shiki
> highlight step rewrites each `<pre>` into raw HTML *before* HAST plugins run, so the old
> `pre: Pre` override never fired. Wrapping at the MDAST stage keeps a real component frame
> while Sätteri still highlights the inner code. The raw `.astro-code` `<pre>` gets its
> monospace font/size from a rule in `src/styles/shiki-transformers.css` (previously supplied
> by the `MarkdownCode` `codeBlock` branch).
## Custom Astro Integration: LLM Markdown
+11 -13
View File
@@ -2,7 +2,7 @@
import process from 'node:process';
import { unified } from '@astrojs/markdown-remark';
import { satteri } from '@astrojs/markdown-satteri';
import mdx from '@astrojs/mdx';
import netlify from '@astrojs/netlify';
import react from '@astrojs/react';
@@ -26,11 +26,11 @@ import yaml from 'shiki/langs/yaml.mjs';
import svgr from 'vite-plugin-svgr';
import llmsMarkdown from './integrations/llms-markdown';
import { PRERELEASE_URL, PRODUCTION_URL } from './src/consts.ts';
import rehypePrepareCodeBlocks from './src/utils/rehypePrepareCodeBlocks';
import remarkConditionalHeadings from './src/utils/remarkConditionalHeadings';
import { remarkReadingTime } from './src/utils/remarkReadingTime.mjs';
import { satteriCodeFrame } from './src/utils/satteriCodeFrame';
import { satteriConditionalHeadings } from './src/utils/satteriConditionalHeadings';
import { satteriReadingTime } from './src/utils/satteriReadingTime';
import { shikiNotationTransformers } from './src/utils/shikiNotationTransformers';
import shikiTransformMetadata from './src/utils/shikiTransformMetadata';
import { shikiStripPreStyle } from './src/utils/shikiStripPreStyle';
// Netlify sets CONTEXT and BRANCH for each deploy. We use them to determine
// the correct site URL:
@@ -144,15 +144,13 @@ export default defineConfig({
...http,
...astro,
],
transformers: [shikiTransformMetadata, ...shikiNotationTransformers],
transformers: [...shikiNotationTransformers, shikiStripPreStyle],
},
// Astro 7 makes Sätteri the default Markdown processor, which does not run
// remark/rehype plugins. Stay on the unified() pipeline so our plugins keep
// working unchanged. unified() applies GFM + SmartyPants by default (which
// is why the previous explicit `gfm`/`smartypants` flags were dropped).
processor: unified({
remarkPlugins: [remarkConditionalHeadings, remarkReadingTime],
rehypePlugins: [rehypePrepareCodeBlocks],
// `syntaxHighlight`/`shikiConfig` are applied by Astro's Shiki layer
// independently of the Markdown processor, so highlighting is configured
// here while the processor's custom transforms live in `mdastPlugins`.
processor: satteri({
mdastPlugins: [satteriReadingTime(), satteriConditionalHeadings(), satteriCodeFrame()],
}),
},
+2 -3
View File
@@ -16,7 +16,7 @@
"test:coverage": "vitest --coverage"
},
"dependencies": {
"@astrojs/markdown-remark": "^7.2.0",
"@astrojs/markdown-satteri": "^0.3.1",
"@astrojs/mdx": "^7.0.0",
"@astrojs/netlify": "^8.0.0",
"@astrojs/react": "^6.0.0",
@@ -44,19 +44,18 @@
"just-throttle": "^4.2.0",
"lucide-react": "^0.546.0",
"marked": "^17.0.1",
"mdast-util-to-string": "^4.0.0",
"nanostores": "^1.0.1",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"reading-time": "^1.5.0",
"satori": "^0.26.0",
"satteri": "^0.9.1",
"schema-dts": "^1.1.5",
"sharp": "^0.34.3",
"shiki": "^4.0.2",
"simple-git": "^3.28.0",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.3.1",
"unist-util-visit": "^5.0.0",
"vite": "^8.0.0"
},
"devDependencies": {
@@ -0,0 +1,26 @@
---
import { Tab, TabsList, TabsPanel, TabsRoot } from '@/components/Tabs.tsx';
/**
* Injected by the `satteriCodeFrame` MDAST plugin,
* which wraps each standalone code block in `<CodeFrame>`.
* Sätteri will handle passing a `<pre>` to the slot.
*/
interface Props {
title?: string;
lang?: string;
}
const { title, lang } = Astro.props;
const label = title || lang || 'code';
const value = 'code';
---
<TabsRoot client:idle>
<TabsList client:idle label={`${label} code`}>
<Tab client:idle value={value} initial>{label}</Tab>
</TabsList>
<TabsPanel client:idle value={value} initial>
<slot />
</TabsPanel>
</TabsRoot>
@@ -6,22 +6,11 @@ import { shared } from './styles';
type Props<Tag extends HTMLTag = 'code'> = Polymorphic<{ as: Tag }> & {
class?: string;
codeBlock?: string;
};
const { as: Tag = 'code', class: className, codeBlock, ...props } = Astro.props;
// When codeBlock="true", render plain code tag (Shiki handles styling)
// Otherwise, render inline code with styling
const isCodeBlock = codeBlock === 'true';
const { as: Tag = 'code', class: className, ...props } = Astro.props;
---
{
isCodeBlock ? (
// prettier-ignore
<Tag class={twMerge(shared.codeBlock, className)} {...props}><slot /></Tag>
) : (
// prettier-ignore
<Tag class={twMerge(shared.code, className)} {...props}><slot /></Tag>
)
}
/* prettier-ignore */}
<Tag class={twMerge(shared.code, className)} {...props}><slot /></Tag>
-38
View File
@@ -1,38 +0,0 @@
---
import type { HTMLTag, Polymorphic } from 'astro/types';
import { Tab, TabsList, TabsPanel, TabsRoot } from '@/components/Tabs.tsx';
type Props<Tag extends HTMLTag = 'pre'> = Polymorphic<{ as: Tag }> & {
maxWidth?: boolean;
class?: string;
hasFrame?: boolean;
title?: string;
};
const { as: Tag = 'pre', maxWidth = false, class: className, style: _style, title, hasFrame, ...props } = Astro.props;
const language = props['data-language'];
const label = title || language || 'code';
const value = 'code';
---
{
hasFrame ? (
// prettier-ignore
<Tag class={className} {...props}><slot /></Tag>
) : (
<TabsRoot client:idle>
<TabsList client:idle label={`${label} code`}>
<Tab client:idle value={value} initial>
{label}
</Tab>
</TabsList>
<TabsPanel client:idle value={value} initial>
{/* prettier-ignore */}
<Tag class={className} {...props}><slot /></Tag>
</TabsPanel>
</TabsRoot>
)
}
@@ -1,5 +1,6 @@
import A from './A.astro';
import Blockquote from './Blockquote.astro';
import CodeFrame from './CodeFrame.astro';
import Em from './Em.astro';
import H1Warning from './H1Warning.astro';
import H2Markdown from './H2Markdown.astro';
@@ -13,7 +14,6 @@ import Li from './Li.astro';
import MarkdownCode from './MarkdownCode.astro';
import Ol from './Ol.astro';
import P from './P.astro';
import Pre from './Pre.astro';
import Strong from './Strong.astro';
import Table from './Table.astro';
import Tbody from './Tbody.astro';
@@ -40,8 +40,8 @@ const defaultMarkdownComponents = {
blockquote: Blockquote,
hr: Hr,
img: Img,
pre: Pre,
code: MarkdownCode,
CodeFrame,
table: Table,
thead: Thead,
tbody: Tbody,
+11
View File
@@ -1,4 +1,15 @@
@layer base {
/* matches `shared.codeBlock`: font-mono + text-code */
.astro-code {
font-family: var(--font-mono), monospace;
font-variant-ligatures: none;
}
/* reinforcing shared.codeBlock */
.astro-code code {
font-size: var(--text-code);
}
.astro-code .line {
display: inline-block;
}
+1 -1
View File
@@ -2,7 +2,7 @@
* Centralized feature API reference subsection definitions.
*
* Mirrors componentReferenceModel.js for feature APIs. Produces heading/id data
* consumed by both FeatureReference.astro and remarkConditionalHeadings.
* consumed by both FeatureReference.astro and satteriConditionalHeadings.
*
* Structure:
* ## API Reference (H2)
+1 -1
View File
@@ -2,7 +2,7 @@
* Centralized media element API subsection definitions.
*
* Mirrors componentReferenceModel.js for media elements. Produces heading/id
* data consumed by both MediaReference.astro and remarkConditionalHeadings.
* data consumed by both MediaReference.astro and satteriConditionalHeadings.
*/
const MEDIA_REFERENCE_SUBSECTIONS = Object.freeze([
-50
View File
@@ -1,50 +0,0 @@
/**
* Adapted from https://mdxjs.com/guides/syntax-highlighting/
*
* This plugin:
* 1. Tags <code> children of <pre> blocks so they know they're in a pre block
* 2. Marks <pre> blocks with hasFrame based on whether they're inside a <TabsPanel> JSX component
*/
export default function rehypePrepareCodeBlocks() {
return (tree) => {
// Process the tree with a stateful visitor
function visitWithContext(node, context = { hasFrame: false }) {
// Handle TabsPanel JSX component
if (node.type === 'mdxJsxFlowElement' && node.name === 'TabsPanel') {
// Create new context for children (inside tabs)
const newContext = { hasFrame: true };
// Visit children with new context
if (node.children) {
node.children.forEach((child) => visitWithContext(child, newContext));
}
return;
}
// Handle <pre> elements
if (node.type === 'element' && node.tagName === 'pre') {
// Mark whether this pre block is inside tabs
node.properties.hasFrame = context.hasFrame;
// Tag <code> children
node.children.forEach((child) => {
if (child.tagName === 'code') {
child.properties.codeBlock = 'true';
}
});
}
// Recursively visit children for other node types
if (node.children) {
node.children.forEach((child) => visitWithContext(child, context));
}
}
// Start visiting from root
if (tree.children) {
tree.children.forEach((child) => visitWithContext(child));
}
};
}
-282
View File
@@ -1,282 +0,0 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import { fileURLToPath } from 'node:url';
import { kebabCase } from 'es-toolkit/string';
import GithubSlugger from 'github-slugger';
import { resolveReferenceSlug } from './api-reference-overrides';
import { buildComponentReferenceTocHeadings, createComponentReferenceModel } from './componentReferenceModel';
import { buildFeatureReferenceTocHeadings, createFeatureReferenceModel } from './featureReferenceModel';
import { buildMediaReferenceTocHeadings, createMediaReferenceModel } from './mediaReferenceModel';
import { buildUtilReferenceTocHeadings, createUtilReferenceModel } from './utilReferenceModel';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const COMPONENT_REF_DIR = path.resolve(__dirname, '../content/generated-component-reference');
const FEATURE_REF_DIR = path.resolve(__dirname, '../content/generated-feature-reference');
const UTIL_REF_DIR = path.resolve(__dirname, '../content/generated-util-reference');
const MEDIA_REF_DIR = path.resolve(__dirname, '../content/generated-media-reference');
function readComponentRefJson(slug) {
const filePath = path.join(COMPONENT_REF_DIR, `${slug}.json`);
try {
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
} catch {
return null;
}
}
/**
* Remark plugin that tracks headings wrapped in FrameworkCase or StyleCase components
* and adds conditional metadata to them.
*
* Also detects `<ComponentReference>` components and injects heading metadata from
* generated JSON, so component-rendered headings appear in the table of contents.
*/
export default function remarkConditionalHeadings() {
return (tree, file) => {
const headingsWithMetadata = [];
const slugger = new GithubSlugger();
const reservedSlugs = new Set();
// Process the tree with a stateful visitor
function visitWithContext(node, context = { frameworks: null, styles: null }) {
// Handle FrameworkCase and StyleCase components
if (node.type === 'mdxJsxFlowElement') {
if (node.name === 'FrameworkCase') {
const frameworksAttr = node.attributes?.find((attr) => attr.name === 'frameworks');
const frameworks = extractArrayValue(frameworksAttr);
// Create new context for children
const newContext = { ...context, frameworks };
// Visit children with new context
if (node.children) {
node.children.forEach((child) => visitWithContext(child, newContext));
}
return;
} else if (node.name === 'StyleCase') {
const stylesAttr = node.attributes?.find((attr) => attr.name === 'styles');
const styles = extractArrayValue(stylesAttr);
// Create new context for children
const newContext = { ...context, styles };
// Visit children with new context
if (node.children) {
node.children.forEach((child) => visitWithContext(child, newContext));
}
return;
} else if (node.name === 'ComponentReference') {
injectComponentReferenceHeadings(node, headingsWithMetadata, reservedSlugs);
} else if (node.name === 'FeatureReference') {
injectFeatureReferenceHeadings(node, headingsWithMetadata, reservedSlugs);
} else if (node.name === 'UtilReference') {
injectUtilReferenceHeadings(node, headingsWithMetadata, reservedSlugs);
return;
} else if (node.name === 'MediaReference') {
injectMediaReferenceHeadings(node, headingsWithMetadata, reservedSlugs);
return;
}
}
// Handle headings
if (node.type === 'heading') {
const text = extractText(node);
let slug = slugger.slug(text);
// Avoid collisions with explicit API reference ids.
while (reservedSlugs.has(slug)) {
slug = slugger.slug(text);
}
reservedSlugs.add(slug);
const metadata = {
depth: node.depth,
text,
slug,
};
// Add conditional context if present
if (context.frameworks) {
metadata.frameworks = context.frameworks;
}
if (context.styles) {
metadata.styles = context.styles;
}
headingsWithMetadata.push(metadata);
}
// Recursively visit children for other node types
if (node.children) {
node.children.forEach((child) => visitWithContext(child, context));
}
}
// Start visiting from root
if (tree.children) {
tree.children.forEach((child) => visitWithContext(child));
}
// Attach to file data for retrieval via remarkPluginFrontmatter
if (!file.data.astro) {
file.data.astro = {};
}
if (!file.data.astro.frontmatter) {
file.data.astro.frontmatter = {};
}
file.data.astro.frontmatter.conditionalHeadings = headingsWithMetadata;
};
}
/**
* Inject heading metadata from generated API reference JSON.
*
* For multi-part components, injects framework-conditional part headings.
* For single-part components, injects "API reference".
* For each, injects Props/State/Data attributes headings
*/
function injectComponentReferenceHeadings(node, headingsWithMetadata, reservedSlugs) {
const componentAttr = node.attributes?.find((a) => a.name === 'component');
const componentName = typeof componentAttr?.value === 'string' ? componentAttr.value : null;
if (!componentName) return;
const json = readComponentRefJson(resolveReferenceSlug(componentName));
if (!json) return;
const partOrderAttr = node.attributes?.find((a) => a.name === 'partOrder');
const partOrder = extractArrayValue(partOrderAttr);
const componentModel = createComponentReferenceModel(componentName, json, partOrder);
const componentHeadings = buildComponentReferenceTocHeadings(componentModel);
headingsWithMetadata.push(...componentHeadings);
for (const heading of componentHeadings) {
reservedSlugs.add(heading.slug);
}
}
function readFeatureRefJson(featureName) {
const filePath = path.join(FEATURE_REF_DIR, `${featureName}.json`);
try {
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
} catch {
return null;
}
}
function injectFeatureReferenceHeadings(node, headingsWithMetadata, reservedSlugs) {
const featureAttr = node.attributes?.find((a) => a.name === 'feature');
const featureName = typeof featureAttr?.value === 'string' ? featureAttr.value : null;
if (!featureName) return;
const json = readFeatureRefJson(featureName);
if (!json) return;
const featureModel = createFeatureReferenceModel(featureName, json);
const featureHeadings = buildFeatureReferenceTocHeadings(featureModel);
headingsWithMetadata.push(...featureHeadings);
for (const heading of featureHeadings) {
reservedSlugs.add(heading.slug);
}
}
function readUtilRefJson(slug) {
const filePath = path.join(UTIL_REF_DIR, `${slug}.json`);
try {
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
} catch {
return null;
}
}
function injectUtilReferenceHeadings(node, headingsWithMetadata, reservedSlugs) {
const utilAttr = node.attributes?.find((a) => a.name === 'util');
const utilName = typeof utilAttr?.value === 'string' ? utilAttr.value : null;
if (!utilName) return;
const slugAttr = node.attributes?.find((a) => a.name === 'slug');
const slugValue = typeof slugAttr?.value === 'string' ? slugAttr.value : null;
const json = readUtilRefJson(slugValue ?? kebabCase(utilName));
if (!json) return;
const utilModel = createUtilReferenceModel(utilName, json);
const utilHeadings = buildUtilReferenceTocHeadings(utilModel);
headingsWithMetadata.push(...utilHeadings);
for (const heading of utilHeadings) {
reservedSlugs.add(heading.slug);
}
}
function readMediaRefJson(tagName) {
const filePath = path.join(MEDIA_REF_DIR, `${tagName}.json`);
try {
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
} catch {
return null;
}
}
function injectMediaReferenceHeadings(node, headingsWithMetadata, reservedSlugs) {
const mediaAttr = node.attributes?.find((a) => a.name === 'media');
const mediaName = typeof mediaAttr?.value === 'string' ? mediaAttr.value : null;
if (!mediaName) return;
const json = readMediaRefJson(resolveReferenceSlug(mediaName));
if (!json) return;
const mediaModel = createMediaReferenceModel(mediaName, json);
const mediaHeadings = buildMediaReferenceTocHeadings(mediaModel);
headingsWithMetadata.push(...mediaHeadings);
for (const heading of mediaHeadings) {
reservedSlugs.add(heading.slug);
}
}
/**
* Extract array value from JSX attribute like frameworks={["react", "html"]}
*/
function extractArrayValue(attr) {
if (!attr || !attr.value) {
return null;
}
// Handle JSX expression
if (attr.value.type === 'mdxJsxAttributeValueExpression') {
const expression = attr.value.value;
try {
// Parse the array from the expression
// This is a simple approach that works for basic arrays
return JSON.parse(expression.trim());
} catch (e) {
console.warn(`Failed to parse JSX expression: ${expression}`, e);
return null;
}
}
return null;
}
/**
* Extract text content from a heading node
*/
function extractText(node) {
if (node.type === 'text') {
return node.value;
}
if (node.type === 'inlineCode') {
return node.value;
}
if (node.children) {
return node.children.map((child) => extractText(child)).join('');
}
return '';
}
-21
View File
@@ -1,21 +0,0 @@
// biome-ignore lint/suspicious/noShadowRestrictedNames: 🤷
import { toString } from 'mdast-util-to-string';
import getReadingTime from 'reading-time';
/**
* Remark plugin that calculates reading time for markdown/MDX content.
* Injects the reading time into remarkPluginFrontmatter for use in templates.
* adapted from https://docs.astro.build/en/recipes/reading-time/
*/
export function remarkReadingTime() {
return (tree, { data }) => {
const textOnPage = toString(tree);
const readingTime = getReadingTime(textOnPage);
// Inject reading time into frontmatter
data.astro.frontmatter.minutesRead = readingTime.text;
// Also provide the numeric minutes value for easier access
data.astro.frontmatter.readingTimeMinutes = readingTime.minutes;
};
}
+24
View File
@@ -0,0 +1,24 @@
import type { MdastPluginInstance } from 'satteri';
/**
* Sätteri doesn't export its visitor-context class, so derive it from a visitor
* signature. Every visitor receives the same context object.
*/
export type MdastVisitorContext = Parameters<NonNullable<MdastPluginInstance['heading']>>[1];
/**
* Shape of the document data bag `@astrojs/markdown-satteri` (and the MDX
* integration's Sätteri path) seed before running plugins. Whatever a plugin
* leaves on `astro.frontmatter` is surfaced to templates as
* `render().remarkPluginFrontmatter`.
*/
interface AstroData {
frontmatter: Record<string, unknown>;
headings: Array<{ depth: number; slug: string; text: string }>;
}
/** Typed accessor for the Astro frontmatter bag a Sätteri plugin writes into. */
export function getAstroFrontmatter(ctx: MdastVisitorContext): Record<string, unknown> | undefined {
const astro = (ctx.data as { astro?: AstroData }).astro;
return astro?.frontmatter;
}
+45
View File
@@ -0,0 +1,45 @@
import type { MdastContent } from 'satteri';
import { defineMdastPlugin } from 'satteri';
import type { MdastVisitorContext } from './satteriAstroData';
const TITLE_RE = /title=(?:"([^"]+)"|'([^']+)'|([^\s"']+))/;
/**
* Wraps each standalone fenced code block in a `<CodeFrame>` component so it
* renders with a filename/language header and a copy button.
*
* This runs at the MDAST stage rather than as a `pre`/`code` component override
* because Shiki rewrites each `<pre>` into raw HTML before any HAST plugin or
* component override runs wrapping the node here keeps a real component frame
* around the still-highlighted code.
*
* Code blocks already inside an authored `<TabsPanel>` are left untouched: the
* tab group is their frame. The title comes from the fence meta
* (e.g. ```ts title="App.ts"```).
*/
export function satteriCodeFrame() {
return defineMdastPlugin({
name: 'astro-code-frame',
code: (node, ctx) => {
// Skip blocks framed by an authored tab group.
let ancestor: ReturnType<MdastVisitorContext['parent']> = ctx.parent(node);
while (ancestor) {
if (ancestor.type === 'mdxJsxFlowElement' && ancestor.name === 'TabsPanel') return;
ancestor = ctx.parent(ancestor);
}
const titleMatch = node.meta?.match(TITLE_RE);
const title = titleMatch ? (titleMatch[1] ?? titleMatch[2] ?? titleMatch[3]) : undefined;
const attributes = [{ type: 'mdxJsxAttribute', name: 'lang', value: node.lang ?? '' }];
if (title) attributes.push({ type: 'mdxJsxAttribute', name: 'title', value: title });
ctx.wrapNode(node, {
type: 'mdxJsxFlowElement',
name: 'CodeFrame',
attributes,
children: [],
} as MdastContent);
},
});
}
@@ -0,0 +1,196 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import { fileURLToPath } from 'node:url';
import { kebabCase } from 'es-toolkit/string';
import GithubSlugger from 'github-slugger';
import type { MdastPluginInput, MdxJsxFlowElement } from 'satteri';
import { defineMdastPlugin } from 'satteri';
import { resolveReferenceSlug } from './api-reference-overrides';
import { buildComponentReferenceTocHeadings, createComponentReferenceModel } from './componentReferenceModel';
import { buildFeatureReferenceTocHeadings, createFeatureReferenceModel } from './featureReferenceModel';
import { buildMediaReferenceTocHeadings, createMediaReferenceModel } from './mediaReferenceModel';
import { getAstroFrontmatter, type MdastVisitorContext } from './satteriAstroData';
import { buildUtilReferenceTocHeadings, createUtilReferenceModel } from './utilReferenceModel';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const COMPONENT_REF_DIR = path.resolve(__dirname, '../content/generated-component-reference');
const FEATURE_REF_DIR = path.resolve(__dirname, '../content/generated-feature-reference');
const UTIL_REF_DIR = path.resolve(__dirname, '../content/generated-util-reference');
const MEDIA_REF_DIR = path.resolve(__dirname, '../content/generated-media-reference');
interface ConditionalHeading {
depth: number;
text: string;
slug: string;
frameworks?: string[];
styles?: string[];
tocKind?: string;
}
/**
* Builds the conditional-heading list used for the docs table of contents.
*
* - Tracks which `<FrameworkCase>` / `<StyleCase>` a heading lives in (walking
* ancestors) and attaches that context.
* - Reads `<ComponentReference>` / `<FeatureReference>` / `<UtilReference>` /
* `<MediaReference>` props, loads the generated JSON, and injects heading
* entries so API-reference sections appear in the TOC.
*
* Markdown headings are slugged with a plain GithubSlugger in document order so
* the slugs match the element ids the markdown-satteri `heading-ids` plugin
* generates (otherwise TOC anchors would not resolve). API-reference headings
* keep their model-generated slugs, which match the ids their components render.
*
* A factory resets the per-document slugger and heading list. Sätteri has no
* end hook, so we publish the (mutated-in-place) array reference onto the
* frontmatter once and keep pushing to it.
*/
export function satteriConditionalHeadings(): MdastPluginInput {
return () => {
const headings: ConditionalHeading[] = [];
const slugger = new GithubSlugger();
let published = false;
const publish = (ctx: MdastVisitorContext) => {
if (published) return;
const frontmatter = getAstroFrontmatter(ctx);
if (!frontmatter) return;
frontmatter.conditionalHeadings = headings;
published = true;
};
return defineMdastPlugin({
name: 'astro-conditional-headings',
heading: (node, ctx) => {
publish(ctx);
const text = ctx.textContent(node);
const heading: ConditionalHeading = {
depth: node.depth,
text,
slug: slugger.slug(text),
};
const { frameworks, styles } = resolveCaseContext(node, ctx);
if (frameworks) heading.frameworks = frameworks;
if (styles) heading.styles = styles;
headings.push(heading);
},
mdxJsxFlowElement: (node, ctx) => {
switch (node.name) {
case 'ComponentReference':
publish(ctx);
injectComponentReferenceHeadings(node, headings);
break;
case 'FeatureReference':
publish(ctx);
injectFeatureReferenceHeadings(node, headings);
break;
case 'UtilReference':
publish(ctx);
injectUtilReferenceHeadings(node, headings);
break;
case 'MediaReference':
publish(ctx);
injectMediaReferenceHeadings(node, headings);
break;
}
},
});
};
}
/** Walk ancestors to find the nearest enclosing FrameworkCase / StyleCase. */
function resolveCaseContext(
node: Parameters<MdastVisitorContext['parent']>[0],
ctx: MdastVisitorContext
): { frameworks: string[] | null; styles: string[] | null } {
let frameworks: string[] | null = null;
let styles: string[] | null = null;
let current = ctx.parent(node);
while (current) {
if (current.type === 'mdxJsxFlowElement') {
const el = current as MdxJsxFlowElement;
if (!frameworks && el.name === 'FrameworkCase') {
frameworks = extractArrayAttr(el, 'frameworks');
} else if (!styles && el.name === 'StyleCase') {
styles = extractArrayAttr(el, 'styles');
}
}
current = ctx.parent(current);
}
return { frameworks, styles };
}
function getStringAttr(node: MdxJsxFlowElement, name: string): string | null {
const attr = node.attributes?.find((a) => a.type === 'mdxJsxAttribute' && a.name === name);
return attr && typeof attr.value === 'string' ? attr.value : null;
}
/** Parse a JSX expression attribute like `frameworks={["react", "html"]}`. */
function extractArrayAttr(node: MdxJsxFlowElement, name: string): string[] | null {
const attr = node.attributes?.find((a) => a.type === 'mdxJsxAttribute' && a.name === name);
if (!attr || !attr.value || typeof attr.value === 'string') return null;
if (attr.value.type !== 'mdxJsxAttributeValueExpression') return null;
try {
return JSON.parse(attr.value.value.trim());
} catch (e) {
console.warn(`Failed to parse JSX expression: ${attr.value.value}`, e);
return null;
}
}
function readRefJson(dir: string, key: string): unknown {
try {
return JSON.parse(fs.readFileSync(path.join(dir, `${key}.json`), 'utf-8'));
} catch {
return null;
}
}
function injectComponentReferenceHeadings(node: MdxJsxFlowElement, headings: ConditionalHeading[]) {
const componentName = getStringAttr(node, 'component');
if (!componentName) return;
const json = readRefJson(COMPONENT_REF_DIR, resolveReferenceSlug(componentName));
if (!json) return;
const partOrder = extractArrayAttr(node, 'partOrder');
const model = createComponentReferenceModel(
componentName,
json as Parameters<typeof createComponentReferenceModel>[1],
partOrder ?? undefined
);
headings.push(...buildComponentReferenceTocHeadings(model));
}
function injectFeatureReferenceHeadings(node: MdxJsxFlowElement, headings: ConditionalHeading[]) {
const featureName = getStringAttr(node, 'feature');
if (!featureName) return;
const json = readRefJson(FEATURE_REF_DIR, featureName);
if (!json) return;
const model = createFeatureReferenceModel(featureName, json);
headings.push(...buildFeatureReferenceTocHeadings(model));
}
function injectUtilReferenceHeadings(node: MdxJsxFlowElement, headings: ConditionalHeading[]) {
const utilName = getStringAttr(node, 'util');
if (!utilName) return;
const slug = getStringAttr(node, 'slug');
const json = readRefJson(UTIL_REF_DIR, slug ?? kebabCase(utilName));
if (!json) return;
const model = createUtilReferenceModel(utilName, json as Parameters<typeof createUtilReferenceModel>[1]);
headings.push(...buildUtilReferenceTocHeadings(model));
}
function injectMediaReferenceHeadings(node: MdxJsxFlowElement, headings: ConditionalHeading[]) {
const mediaName = getStringAttr(node, 'media');
if (!mediaName) return;
const json = readRefJson(MEDIA_REF_DIR, resolveReferenceSlug(mediaName));
if (!json) return;
const model = createMediaReferenceModel(mediaName, json);
headings.push(...buildMediaReferenceTocHeadings(model));
}
+35
View File
@@ -0,0 +1,35 @@
import getReadingTime from 'reading-time';
import type { MdastPluginInput } from 'satteri';
import { defineMdastPlugin } from 'satteri';
import { getAstroFrontmatter, type MdastVisitorContext } from './satteriAstroData';
/**
* Calculates reading time and injects it into the Astro frontmatter bag for
* templates (read via `remarkPluginFrontmatter`).
*
* Returned as a factory so the text accumulator resets per document. Sätteri
* has no end-of-document hook, so text is accumulated across literal nodes and
* the reading time is recomputed as it grows; the final visit leaves the
* correct value on the frontmatter.
*/
export function satteriReadingTime(): MdastPluginInput {
return () => {
let text = '';
const accumulate = (value: string, ctx: MdastVisitorContext) => {
text += `${value} `;
const frontmatter = getAstroFrontmatter(ctx);
if (!frontmatter) return;
const readingTime = getReadingTime(text);
frontmatter.minutesRead = readingTime.text;
frontmatter.readingTimeMinutes = readingTime.minutes;
};
return defineMdastPlugin({
name: 'astro-reading-time',
text: (node, ctx) => accumulate(node.value, ctx),
inlineCode: (node, ctx) => accumulate(node.value, ctx),
code: (node, ctx) => accumulate(node.value, ctx),
});
};
}
+19
View File
@@ -0,0 +1,19 @@
import type { ShikiTransformer } from 'shiki';
/**
* Strip the inline `style` Astro's Shiki highlighter writes onto the `<pre>`
* (the theme `background-color`/`color` and a trailing `overflow-x: auto`).
*
* Shiki should only highlight the text; the code container's background and
* scrolling are owned by `CodeFrame` and the `.astro-code` rules. Token colors
* live on the inner spans, so removing the pre's style leaves them untouched.
*
* Astro adds its built-in `pre` transformer before user transformers, so this
* one runs last and sees the fully-assembled style to remove.
*/
export const shikiStripPreStyle: ShikiTransformer = {
name: 'strip-pre-inline-style',
pre(node) {
delete node.properties.style;
},
};
-12
View File
@@ -1,12 +0,0 @@
const shikiTransformMetadata = {
pre(hast) {
// get stuff out of this.options.meta?.__raw;
// for now, let's start with just title="abc" or title='abc' or title=abc
const raw = this.options.meta?.__raw || '';
const titleMatch = raw.match(/title=(?:"([^"]+)"|'([^']+)'|([^\s"']+))/);
if (titleMatch) {
hast.properties.title = titleMatch[1] || titleMatch[2] || titleMatch[3];
}
},
};
export default shikiTransformMetadata;
@@ -1,4 +1,4 @@
// @ts-nocheck — the model is plain JS shared with remarkConditionalHeadings
// @ts-nocheck — the model is plain JS shared with satteriConditionalHeadings
import { describe, expect, it } from 'vitest';
import { buildMediaReferenceTocHeadings, createMediaReferenceModel } from '../mediaReferenceModel';
@@ -0,0 +1,37 @@
// @vitest-environment node
// Sätteri's native binding builds typed-array buffers that fail against jsdom's
// patched ArrayBuffer/DataView globals; run these against the real node realm.
import { mdxToJs } from 'satteri';
import { describe, expect, it } from 'vitest';
import { satteriCodeFrame } from '../satteriCodeFrame';
function compile(source: string): string {
const data = {
astro: {
frontmatter: {},
headings: [],
localImagePaths: new Set<string>(),
remoteImagePaths: new Set<string>(),
},
};
const { code } = mdxToJs(source, { mdastPlugins: [satteriCodeFrame()], data });
return code;
}
describe('satteriCodeFrame', () => {
it('wraps a standalone code block in CodeFrame', () => {
const code = compile('```ts\nconst a = 1;\n```');
expect(code).toContain('CodeFrame');
});
it('passes the fence title and language as props', () => {
const code = compile('```ts title="App.ts"\nconst a = 1;\n```');
expect(code).toContain('App.ts');
expect(code).toContain('ts');
});
it('does not wrap a code block already inside a TabsPanel', () => {
const code = compile('<TabsPanel value="npm">\n\n```bash\nnpm i\n```\n\n</TabsPanel>');
expect(code).not.toContain('CodeFrame');
});
});
@@ -0,0 +1,50 @@
// @vitest-environment node
// Sätteri's native binding builds typed-array buffers that fail against jsdom's
// patched ArrayBuffer/DataView globals; run these against the real node realm.
import { mdxToJs } from 'satteri';
import { describe, expect, it } from 'vitest';
import { satteriConditionalHeadings } from '../satteriConditionalHeadings';
interface Heading {
depth: number;
text: string;
slug: string;
frameworks?: string[];
styles?: string[];
}
function collect(source: string): Heading[] {
const data = {
astro: {
frontmatter: {} as Record<string, unknown>,
headings: [],
localImagePaths: new Set<string>(),
remoteImagePaths: new Set<string>(),
},
};
mdxToJs(source, { mdastPlugins: [satteriConditionalHeadings()], data });
return (data.astro.frontmatter.conditionalHeadings ?? []) as Heading[];
}
describe('satteriConditionalHeadings', () => {
it('collects headings with github-style slugs in document order', () => {
const headings = collect('## Hello World\n\n### Nested Heading');
expect(headings).toEqual([
{ depth: 2, text: 'Hello World', slug: 'hello-world' },
{ depth: 3, text: 'Nested Heading', slug: 'nested-heading' },
]);
});
it('attaches framework context from an enclosing FrameworkCase', () => {
const headings = collect(
'## Shared\n\n<FrameworkCase frameworks={["react"]}>\n\n## React Only\n\n</FrameworkCase>'
);
expect(headings.find((h) => h.text === 'Shared')?.frameworks).toBeUndefined();
expect(headings.find((h) => h.text === 'React Only')?.frameworks).toEqual(['react']);
});
it('attaches style context from an enclosing StyleCase', () => {
const headings = collect('<StyleCase styles={["css"]}>\n\n## CSS Only\n\n</StyleCase>');
expect(headings.find((h) => h.text === 'CSS Only')?.styles).toEqual(['css']);
});
});
@@ -0,0 +1,35 @@
// @vitest-environment node
// Sätteri's native binding builds typed-array buffers that fail against jsdom's
// patched ArrayBuffer/DataView globals; run these against the real node realm.
import { markdownToHtml } from 'satteri';
import { describe, expect, it } from 'vitest';
import { satteriReadingTime } from '../satteriReadingTime';
function render(source: string) {
const data = {
astro: {
frontmatter: {} as Record<string, unknown>,
headings: [],
localImagePaths: new Set<string>(),
remoteImagePaths: new Set<string>(),
},
};
markdownToHtml(source, { mdastPlugins: [satteriReadingTime()], data });
return data.astro.frontmatter;
}
describe('satteriReadingTime', () => {
it('injects reading time into the frontmatter bag', () => {
const words = Array.from({ length: 500 }, (_, i) => `word${i}`).join(' ');
const frontmatter = render(`# Title\n\n${words}`);
expect(frontmatter.minutesRead).toMatch(/min read/);
expect(typeof frontmatter.readingTimeMinutes).toBe('number');
expect(frontmatter.readingTimeMinutes as number).toBeGreaterThan(0);
});
it('counts code and inline code toward the total', () => {
const withCode = render('# Title\n\nSome `inline` text\n\n```ts\nconst a = 1;\n```');
expect(withCode.minutesRead).toMatch(/min read/);
});
});