mirror of
https://github.com/zoriya/v10.git
synced 2026-08-05 05:37:21 +00:00
perf(site): migrate markdown pipeline to Sätteri (#1733)
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Generated
+6
-9
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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 '';
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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),
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user