@hutusi/amytis 1.14.0 → 1.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +1 -1
- package/.github/workflows/publish.yml +2 -2
- package/CHANGELOG.md +42 -0
- package/CLAUDE.md +90 -219
- package/README.md +33 -1
- package/README.zh.md +33 -1
- package/TODO.md +10 -0
- package/bun.lock +205 -539
- package/content/books/sample-book/index.mdx +3 -0
- package/content/posts/code-block-features-showcase.mdx +223 -0
- package/content/series/rst-legacy/deeper-notes/images/test.svg +4 -0
- package/content/series/rst-legacy/deeper-notes/index.rst +15 -0
- package/content/series/rst-legacy/getting-started.rst +24 -0
- package/content/series/rst-legacy/index.rst +9 -0
- package/content/series/rst-readme/README.rst +9 -0
- package/content/series/rst-readme/readme-index-post.rst +10 -0
- package/content/series/rst-toctree/first-post.rst +6 -0
- package/content/series/rst-toctree/index.rst +10 -0
- package/content/series/rst-toctree/second-post.rst +6 -0
- package/content/series/rst-toctree-precedence/first-post.rst +6 -0
- package/content/series/rst-toctree-precedence/index.rst +12 -0
- package/content/series/rst-toctree-precedence/second-post.rst +6 -0
- package/docs/ALERTS.md +112 -0
- package/docs/ARCHITECTURE.md +239 -8
- package/docs/CODE-BLOCKS.md +238 -0
- package/docs/CONTRIBUTING.md +36 -0
- package/docs/guides/README.md +11 -0
- package/docs/guides/importing-vuepress-books.md +178 -0
- package/eslint.config.mjs +20 -6
- package/next.config.ts +2 -2
- package/package.json +52 -24
- package/packages/create-amytis/package.json +1 -1
- package/packages/create-amytis/src/index.test.ts +43 -1
- package/packages/create-amytis/src/index.ts +64 -8
- package/public/next-image-export-optimizer-hashes.json +14 -73
- package/scripts/build-pagefind.ts +172 -0
- package/scripts/copy-assets.ts +246 -56
- package/scripts/generate-code-group-icons.ts +79 -0
- package/scripts/generate-knowledge-graph.ts +2 -1
- package/scripts/render-rst.py +923 -0
- package/scripts/run-with-rst-python.ts +42 -0
- package/scripts/sync-vuepress-book.ts +499 -0
- package/src/app/[slug]/[postSlug]/page.tsx +20 -10
- package/src/app/[slug]/page/[page]/page.tsx +15 -0
- package/src/app/books/[slug]/{[chapter] → [...chapter]}/page.tsx +32 -10
- package/src/app/books/[slug]/page.tsx +67 -32
- package/src/app/globals.css +639 -94
- package/src/app/page.tsx +1 -1
- package/src/app/series/[slug]/page/[page]/page.tsx +74 -6
- package/src/app/series/[slug]/page.tsx +11 -13
- package/src/app/series/page.tsx +3 -3
- package/src/app/sitemap.ts +3 -3
- package/src/components/ArticleCopyCleaner.tsx +64 -0
- package/src/components/AuthorCard.tsx +25 -16
- package/src/components/BookMobileNav.tsx +44 -50
- package/src/components/BookSidebar.tsx +0 -0
- package/src/components/CodeBlock.test.tsx +93 -8
- package/src/components/CodeBlock.tsx +39 -101
- package/src/components/CodeBlockToolbar.tsx +88 -0
- package/src/components/CodeGroup.tsx +81 -0
- package/src/components/CoverImage.tsx +6 -2
- package/src/components/ExternalLinkIcon.tsx +15 -0
- package/src/components/FeaturedStoriesSection.tsx +3 -3
- package/src/components/GithubAlert.tsx +97 -0
- package/src/components/MarkdownRenderer.test.tsx +30 -4
- package/src/components/MarkdownRenderer.tsx +148 -24
- package/src/components/Mermaid.tsx +32 -1
- package/src/components/PostList.tsx +1 -1
- package/src/components/PostNavigation.tsx +13 -2
- package/src/components/PostSidebar.tsx +13 -2
- package/src/components/RstRenderer.test.tsx +93 -0
- package/src/components/RstRenderer.tsx +157 -0
- package/src/components/Search.tsx +18 -4
- package/src/components/SeriesCatalog.tsx +1 -1
- package/src/components/ShareBar.tsx +5 -0
- package/src/components/TocPanel.tsx +10 -2
- package/src/i18n/translations.ts +2 -0
- package/src/layouts/BookLayout.tsx +35 -4
- package/src/layouts/PostLayout.tsx +10 -2
- package/src/layouts/SimpleLayout.tsx +10 -3
- package/src/lib/code-group-icons.test.ts +78 -0
- package/src/lib/code-group-icons.ts +148 -0
- package/src/lib/image-utils.test.ts +19 -0
- package/src/lib/image-utils.ts +11 -0
- package/src/lib/markdown.test.ts +195 -14
- package/src/lib/markdown.ts +928 -254
- package/src/lib/normalize-vuepress-math.ts +118 -0
- package/src/lib/rehype-fence-meta.ts +22 -0
- package/src/lib/rehype-image-metadata.ts +2 -2
- package/src/lib/remark-book-chapter-links.ts +106 -0
- package/src/lib/remark-code-group.ts +54 -0
- package/src/lib/remark-github-alerts.test.ts +83 -0
- package/src/lib/remark-github-alerts.ts +65 -0
- package/src/lib/remark-vuepress-containers.ts +130 -0
- package/src/lib/rst-renderer.test.ts +355 -0
- package/src/lib/rst-renderer.ts +629 -0
- package/src/lib/rst.test.ts +350 -0
- package/src/lib/rst.ts +674 -0
- package/src/lib/series-redirects.ts +42 -0
- package/src/lib/shiki-rst.ts +185 -0
- package/src/lib/shiki.test.ts +153 -0
- package/src/lib/shiki.ts +292 -0
- package/src/lib/urls.ts +57 -0
- package/src/test-utils/render.ts +23 -0
- package/tests/fixtures/sync-vuepress-book/docs/.vuepress/config.js +43 -0
- package/tests/fixtures/sync-vuepress-book/docs/intro/welcome.md +7 -0
- package/tests/fixtures/sync-vuepress-book/docs/maths/linear/assets/diagram.png +1 -0
- package/tests/fixtures/sync-vuepress-book/docs/maths/linear/matrices.md +7 -0
- package/tests/fixtures/sync-vuepress-book/docs/maths/linear/vectors.md +9 -0
- package/tests/helpers/env.ts +19 -0
- package/tests/integration/book-chapter-links.test.ts +107 -0
- package/tests/integration/books-nested-toc.test.ts +176 -0
- package/tests/integration/books.test.ts +3 -2
- package/tests/integration/code-block-features.test.ts +188 -0
- package/tests/integration/code-group.test.ts +183 -0
- package/tests/integration/code-notation.test.ts +97 -0
- package/tests/integration/feed-utils.test.ts +13 -0
- package/tests/integration/github-alerts.test.ts +82 -0
- package/tests/integration/markdown-external-links.test.ts +103 -0
- package/tests/integration/normalize-vuepress-math.test.ts +149 -0
- package/tests/integration/reading-time-headings.test.ts +12 -14
- package/tests/integration/series-draft.test.ts +12 -5
- package/tests/integration/series.test.ts +93 -0
- package/tests/integration/sync-vuepress-book.test.ts +240 -0
- package/tests/integration/vuepress-containers.test.ts +107 -0
- package/tests/tooling/build-pagefind.test.ts +66 -0
- package/tests/tooling/new-post.test.ts +1 -1
- package/tests/unit/static-params.test.ts +166 -13
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
const FENCE_OPEN_RE = /^[ \t]*(`{3,}|~{3,})/;
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* VuePress's math plugin accepts block math with the `$$` markers on the
|
|
5
|
+
* same line as the math body — `$$ \mathbf{A} = \begin{bmatrix}` opening
|
|
6
|
+
* and `\end{bmatrix} $$` closing. `remark-math` (the upstream micromark
|
|
7
|
+
* extension) is stricter: a block-math opener must be on its own line and
|
|
8
|
+
* the closer must be on its own line. When that's violated, the parser
|
|
9
|
+
* falls back to *inline* math — which either explodes in KaTeX (when the
|
|
10
|
+
* body contains `\\` line breaks or `&` column separators) or silently
|
|
11
|
+
* mis-renders as inline (no `katex-display` wrapper, so it loses block
|
|
12
|
+
* margin and centering even though it visually looks fine).
|
|
13
|
+
*
|
|
14
|
+
* This pre-processor splits VuePress-style fences onto their own lines
|
|
15
|
+
* before parsing, so imported chapters render correctly without touching
|
|
16
|
+
* their source files. Two cases:
|
|
17
|
+
*
|
|
18
|
+
* $$ \mathbf{A} = \begin{bmatrix} $$
|
|
19
|
+
* a & b \\ becomes → \mathbf{A} = \begin{bmatrix}
|
|
20
|
+
* c & d a & b \\
|
|
21
|
+
* \end{bmatrix} $$ c & d
|
|
22
|
+
* \end{bmatrix}
|
|
23
|
+
* $$
|
|
24
|
+
*
|
|
25
|
+
* $$
|
|
26
|
+
* $$ x = y $$ becomes → x = y
|
|
27
|
+
* $$
|
|
28
|
+
*
|
|
29
|
+
* - Inline math (`$x$`, with a single `$`) is never matched — only `$$`.
|
|
30
|
+
* - Empty single-line blocks (`$$$$`, `$$ $$`) are left alone.
|
|
31
|
+
* - Fenced code blocks (``` and ~~~) are skipped so code samples that
|
|
32
|
+
* *show* the VuePress syntax verbatim aren't mutated. Fence semantics
|
|
33
|
+
* match CommonMark: closer must be the same character type and at
|
|
34
|
+
* least as long as the opener.
|
|
35
|
+
*
|
|
36
|
+
* Idempotent: re-running on already-normalized content is a no-op, so
|
|
37
|
+
* it's safe to apply unconditionally whenever LaTeX rendering is on.
|
|
38
|
+
*/
|
|
39
|
+
export function normalizeVuepressBlockMath(source: string): string {
|
|
40
|
+
const lines = source.split('\n');
|
|
41
|
+
const out: string[] = [];
|
|
42
|
+
let inMath = false;
|
|
43
|
+
let openFence: string | null = null;
|
|
44
|
+
// Indent of the current block-math opener — preserved on every emitted
|
|
45
|
+
// synthetic line so list-item-nested math (4-space indent inside a `-`
|
|
46
|
+
// bullet) doesn't lose its list context when we split the opener / closer.
|
|
47
|
+
let blockIndent = '';
|
|
48
|
+
|
|
49
|
+
for (const line of lines) {
|
|
50
|
+
if (openFence !== null) {
|
|
51
|
+
// Inside a code fence — pass through verbatim, just track close.
|
|
52
|
+
out.push(line);
|
|
53
|
+
const closeRe = new RegExp(`^[ \\t]*${openFence[0]}{${openFence.length},}\\s*$`);
|
|
54
|
+
if (closeRe.test(line)) openFence = null;
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const fenceOpen = line.match(FENCE_OPEN_RE);
|
|
59
|
+
if (fenceOpen) {
|
|
60
|
+
openFence = fenceOpen[1];
|
|
61
|
+
out.push(line);
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (!inMath) {
|
|
66
|
+
// Match an opener with content tacked on after `$$`. The trimmed body
|
|
67
|
+
// must NOT itself end in `$$` — that would make it a single-line block.
|
|
68
|
+
const m = line.match(/^([ \t]*)\$\$(.+)$/);
|
|
69
|
+
if (m) {
|
|
70
|
+
const rest = m[2].trimEnd();
|
|
71
|
+
if (rest.endsWith('$$')) {
|
|
72
|
+
// Single-line block math like `$$ x $$`. micromark-extension-math
|
|
73
|
+
// parses this as *inline* math (no `katex-display` wrapper), so
|
|
74
|
+
// expand it to opener / body / closer on three lines.
|
|
75
|
+
const body = rest.slice(0, -2).trim();
|
|
76
|
+
if (body.length === 0) {
|
|
77
|
+
// `$$$$` or `$$ $$` — degenerate, leave alone.
|
|
78
|
+
out.push(line);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
out.push(`${m[1]}$$`);
|
|
82
|
+
out.push(`${m[1]}${body}`);
|
|
83
|
+
out.push(`${m[1]}$$`);
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
blockIndent = m[1];
|
|
87
|
+
out.push(`${blockIndent}$$`);
|
|
88
|
+
// Re-apply the opener's indent on the math body so list-nested
|
|
89
|
+
// blocks stay inside their list item. Trim only the gap between
|
|
90
|
+
// `$$` and the actual math content (e.g. `$$ \mathbf{A}` → `\mathbf{A}`).
|
|
91
|
+
out.push(`${blockIndent}${m[2].trimStart()}`);
|
|
92
|
+
inMath = true;
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
out.push(line);
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Inside a block-math run started above. Look for an inline closer.
|
|
100
|
+
const close = line.match(/^(.*?)[ \t]*\$\$[ \t]*$/);
|
|
101
|
+
if (close && !line.trim().startsWith('$$')) {
|
|
102
|
+
// Closer with content before `$$` on the same line — split.
|
|
103
|
+
// `close[1]` already includes its own leading whitespace, so we don't
|
|
104
|
+
// need to re-apply blockIndent to it; we only need to indent the `$$`.
|
|
105
|
+
if (close[1].length > 0) out.push(close[1]);
|
|
106
|
+
out.push(`${blockIndent}$$`);
|
|
107
|
+
inMath = false;
|
|
108
|
+
blockIndent = '';
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
out.push(line);
|
|
112
|
+
if (line.trim() === '$$') {
|
|
113
|
+
inMath = false;
|
|
114
|
+
blockIndent = '';
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return out.join('\n');
|
|
118
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { visit } from 'unist-util-visit';
|
|
2
|
+
import type { Element, Root } from 'hast';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* mdast-util-to-hast preserves a fenced code block's meta string under
|
|
6
|
+
* `node.data.meta`, but react-markdown v10 strips `data` before invoking
|
|
7
|
+
* component overrides — so the meta becomes invisible at render time.
|
|
8
|
+
* This tiny rehype pass copies it to a real `data-meta` HTML attribute
|
|
9
|
+
* that survives the round trip and is reachable as `props['data-meta']`.
|
|
10
|
+
*/
|
|
11
|
+
export default function rehypeFenceMeta() {
|
|
12
|
+
return (tree: Root) => {
|
|
13
|
+
visit(tree, 'element', (node: Element) => {
|
|
14
|
+
if (node.tagName !== 'code') return;
|
|
15
|
+
const meta = (node.data as { meta?: string } | undefined)?.meta;
|
|
16
|
+
if (typeof meta === 'string' && meta.length > 0) {
|
|
17
|
+
node.properties = node.properties ?? {};
|
|
18
|
+
node.properties['data-meta'] = meta;
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
};
|
|
22
|
+
}
|
|
@@ -53,8 +53,8 @@ export default function rehypeImageMetadata(options: Options) {
|
|
|
53
53
|
|
|
54
54
|
// Enrich with dimensions only when the file is available locally
|
|
55
55
|
try {
|
|
56
|
-
if (imagePath && fs.existsSync(imagePath)) {
|
|
57
|
-
const buffer = fs.readFileSync(imagePath);
|
|
56
|
+
if (imagePath && fs.existsSync(/* turbopackIgnore: true */ imagePath)) {
|
|
57
|
+
const buffer = fs.readFileSync(/* turbopackIgnore: true */ imagePath);
|
|
58
58
|
const dimensions = sizeOf(buffer);
|
|
59
59
|
if (dimensions) {
|
|
60
60
|
node.properties.width = dimensions.width;
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { visit } from 'unist-util-visit';
|
|
2
|
+
import type { Root, Link } from 'mdast';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { getBookChapterUrl } from './urls';
|
|
5
|
+
|
|
6
|
+
export interface BookChapterLinksOptions {
|
|
7
|
+
/** Slug of the book being rendered (passed to getBookChapterUrl). */
|
|
8
|
+
bookSlug: string;
|
|
9
|
+
/** Absolute path to the book directory (e.g. content/books/dmla). */
|
|
10
|
+
bookDir: string;
|
|
11
|
+
/** Absolute path of the chapter source file (e.g. content/books/dmla/maths/linear/introduction.md). */
|
|
12
|
+
chapterSourcePath: string;
|
|
13
|
+
/** Set of valid chapter ids for the book — used to validate link targets. */
|
|
14
|
+
validChapterIds: ReadonlySet<string>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const EXTERNAL_RE = /^(?:https?:|mailto:|tel:|ftp:|\/\/|#)/i;
|
|
18
|
+
const MD_LINK_RE = /\.(?:md|mdx)(?:#([^?]*))?$/i;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Rewrites relative `.md` / `.mdx` links in a book chapter to canonical
|
|
22
|
+
* `/books/<slug>/<chapter-id>/[#fragment]` URLs, so the cross-references that
|
|
23
|
+
* exist in a VuePress source repo (where chapters live in nested folders and
|
|
24
|
+
* link to each other via `[向量](vectors.md)` or `[张量](matrices.md#张量)`)
|
|
25
|
+
* keep working after the content is imported flat into Amytis's book layout.
|
|
26
|
+
*
|
|
27
|
+
* Resolution strategy
|
|
28
|
+
* ───────────────────
|
|
29
|
+
* - Skip external links (http, mailto, //, hash-only).
|
|
30
|
+
* - Strip an optional `#fragment` suffix; remember it for re-attachment.
|
|
31
|
+
* - Resolve the remaining path relative to `chapterSourcePath`'s directory.
|
|
32
|
+
* - Make the result relative to `bookDir`, drop the `.md`/`.mdx` extension,
|
|
33
|
+
* and treat the resulting POSIX path as the chapter id.
|
|
34
|
+
* - Validate the id against `validChapterIds`.
|
|
35
|
+
* - If the link escapes the book directory, **throw** — that's almost
|
|
36
|
+
* always a real bug (typo in `../../../somewhere`).
|
|
37
|
+
* - If the chapter id is well-formed but not in the TOC (e.g. the author
|
|
38
|
+
* links to a chapter they haven't written yet, or that's commented out
|
|
39
|
+
* of the sidebar), **warn and leave the link unrewritten** instead of
|
|
40
|
+
* blocking the build. Matches the Shiki precedent in CLAUDE.md: a
|
|
41
|
+
* single broken cross-reference shouldn't fail a production deploy.
|
|
42
|
+
* The warning still surfaces in CI build logs.
|
|
43
|
+
*/
|
|
44
|
+
const warned = new Set<string>();
|
|
45
|
+
export default function remarkBookChapterLinks(options: BookChapterLinksOptions) {
|
|
46
|
+
const { bookSlug, bookDir, chapterSourcePath, validChapterIds } = options;
|
|
47
|
+
const chapterDir = path.dirname(chapterSourcePath);
|
|
48
|
+
const bookDirResolved = path.resolve(bookDir);
|
|
49
|
+
|
|
50
|
+
return (tree: Root) => {
|
|
51
|
+
visit(tree, 'link', (node: Link) => {
|
|
52
|
+
const url = node.url;
|
|
53
|
+
if (!url || EXTERNAL_RE.test(url)) return;
|
|
54
|
+
const match = MD_LINK_RE.exec(url);
|
|
55
|
+
if (!match) return;
|
|
56
|
+
|
|
57
|
+
// Split fragment from path.
|
|
58
|
+
const hashIdx = url.indexOf('#');
|
|
59
|
+
const fragment = hashIdx >= 0 ? url.slice(hashIdx + 1) : '';
|
|
60
|
+
const pathPart = hashIdx >= 0 ? url.slice(0, hashIdx) : url;
|
|
61
|
+
|
|
62
|
+
// Resolve to absolute, then back to a bookDir-relative POSIX path.
|
|
63
|
+
// decodeURIComponent throws URIError on malformed `%XX`; swallow that
|
|
64
|
+
// and fall back to the raw string so a single broken percent-encoded
|
|
65
|
+
// link doesn't 500 the build (matches the broader "warn don't throw"
|
|
66
|
+
// posture for stale cross-references below).
|
|
67
|
+
let decodedPath: string;
|
|
68
|
+
try {
|
|
69
|
+
decodedPath = decodeURIComponent(pathPart);
|
|
70
|
+
} catch {
|
|
71
|
+
decodedPath = pathPart;
|
|
72
|
+
}
|
|
73
|
+
const resolvedAbs = path.resolve(chapterDir, decodedPath);
|
|
74
|
+
const inside =
|
|
75
|
+
resolvedAbs === bookDirResolved ||
|
|
76
|
+
resolvedAbs.startsWith(bookDirResolved + path.sep);
|
|
77
|
+
if (!inside) {
|
|
78
|
+
throw new Error(
|
|
79
|
+
`[amytis] Book chapter link "${url}" in ${chapterSourcePath} resolves ` +
|
|
80
|
+
`outside the book directory ${bookDirResolved}. Cross-book links are not supported.`
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const rel = path.relative(bookDirResolved, resolvedAbs).split(path.sep).join('/');
|
|
85
|
+
const chapterId = rel.replace(/\.(?:md|mdx)$/i, '').replace(/\/index$/i, '');
|
|
86
|
+
|
|
87
|
+
if (!validChapterIds.has(chapterId)) {
|
|
88
|
+
const warnKey = `${bookSlug}::${chapterId}`;
|
|
89
|
+
if (!warned.has(warnKey)) {
|
|
90
|
+
warned.add(warnKey);
|
|
91
|
+
console.warn(
|
|
92
|
+
`[amytis] Book chapter link "${url}" in ${chapterSourcePath} points to ` +
|
|
93
|
+
`chapter id "${chapterId}", which is not in book "${bookSlug}"'s TOC. ` +
|
|
94
|
+
`Leaving link unrewritten — it will 404 if clicked. To fix, add the ` +
|
|
95
|
+
`chapter to index.mdx or remove the link.`
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
node.url = fragment
|
|
102
|
+
? `${getBookChapterUrl(bookSlug, chapterId)}#${fragment}`
|
|
103
|
+
: getBookChapterUrl(bookSlug, chapterId);
|
|
104
|
+
});
|
|
105
|
+
};
|
|
106
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { visit } from 'unist-util-visit';
|
|
2
|
+
import type { Root, Code } from 'mdast';
|
|
3
|
+
import { parseFenceMeta } from './shiki';
|
|
4
|
+
|
|
5
|
+
interface ContainerDirective {
|
|
6
|
+
type: 'containerDirective';
|
|
7
|
+
name: string;
|
|
8
|
+
children: Array<Code | { type: string }>;
|
|
9
|
+
data?: { hName?: string; hProperties?: Record<string, unknown> };
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
let counter = 0;
|
|
13
|
+
function nextGroupId(): string {
|
|
14
|
+
counter += 1;
|
|
15
|
+
return `cg${counter.toString(36)}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Transforms `:::code-group ... :::` container directives into a custom hast
|
|
20
|
+
* element <code-group data-labels="[...]" data-group-id="..."> whose children
|
|
21
|
+
* are the original fenced code blocks (still processed by the normal `code`
|
|
22
|
+
* override and Shiki pipeline). The component override for `code-group` is
|
|
23
|
+
* <CodeGroup>, which renders the radio+label tabs HTML.
|
|
24
|
+
*
|
|
25
|
+
* Labels come from the Docusaurus-style `[label]` token at the start of each
|
|
26
|
+
* fence's meta (e.g. ```bash [npm]). Falls back to the language name when
|
|
27
|
+
* absent, then to "Tab N" when both are missing.
|
|
28
|
+
*/
|
|
29
|
+
export default function remarkCodeGroup() {
|
|
30
|
+
return (tree: Root) => {
|
|
31
|
+
visit(tree, (node) => {
|
|
32
|
+
if (node.type !== 'containerDirective') return;
|
|
33
|
+
const directive = node as unknown as ContainerDirective;
|
|
34
|
+
if (directive.name !== 'code-group') return;
|
|
35
|
+
|
|
36
|
+
const labels: string[] = [];
|
|
37
|
+
let tabIndex = 0;
|
|
38
|
+
for (const child of directive.children) {
|
|
39
|
+
if (child.type !== 'code') continue;
|
|
40
|
+
const code = child as Code;
|
|
41
|
+
const parsed = parseFenceMeta(code.meta ?? undefined);
|
|
42
|
+
tabIndex += 1;
|
|
43
|
+
labels.push(parsed.tabLabel ?? code.lang ?? `Tab ${tabIndex}`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
directive.data = directive.data ?? {};
|
|
47
|
+
directive.data.hName = 'code-group';
|
|
48
|
+
directive.data.hProperties = {
|
|
49
|
+
'data-labels': JSON.stringify(labels),
|
|
50
|
+
'data-group-id': nextGroupId(),
|
|
51
|
+
};
|
|
52
|
+
});
|
|
53
|
+
};
|
|
54
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { unified } from 'unified';
|
|
3
|
+
import remarkParse from 'remark-parse';
|
|
4
|
+
import remarkGfm from 'remark-gfm';
|
|
5
|
+
import type { Root, Blockquote } from 'mdast';
|
|
6
|
+
import remarkGithubAlerts from './remark-github-alerts';
|
|
7
|
+
|
|
8
|
+
function parse(markdown: string): Root {
|
|
9
|
+
return unified()
|
|
10
|
+
.use(remarkParse)
|
|
11
|
+
.use(remarkGfm)
|
|
12
|
+
.use(remarkGithubAlerts)
|
|
13
|
+
.runSync(unified().use(remarkParse).parse(markdown)) as Root;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function findBlockquote(tree: Root): Blockquote | undefined {
|
|
17
|
+
return tree.children.find((n): n is Blockquote => n.type === 'blockquote');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe('remarkGithubAlerts', () => {
|
|
21
|
+
test('transforms [!NOTE] blockquote into a github-alert hast element', () => {
|
|
22
|
+
const tree = parse('> [!NOTE]\n> body content');
|
|
23
|
+
const bq = findBlockquote(tree);
|
|
24
|
+
const data = bq?.data as { hName?: string; hProperties?: Record<string, unknown> } | undefined;
|
|
25
|
+
expect(data?.hName).toBe('github-alert');
|
|
26
|
+
expect(data?.hProperties?.['data-alert-type']).toBe('note');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test.each(['NOTE', 'TIP', 'IMPORTANT', 'WARNING', 'CAUTION'])(
|
|
30
|
+
'matches [!%s] case-sensitively (uppercase) and lowercases the type',
|
|
31
|
+
(typeUpper) => {
|
|
32
|
+
const tree = parse(`> [!${typeUpper}]\n> body`);
|
|
33
|
+
const bq = findBlockquote(tree);
|
|
34
|
+
const data = bq?.data as { hProperties?: Record<string, unknown> } | undefined;
|
|
35
|
+
expect(data?.hProperties?.['data-alert-type']).toBe(typeUpper.toLowerCase());
|
|
36
|
+
},
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
test('matches lowercase [!note] too (GitHub is case-insensitive)', () => {
|
|
40
|
+
const tree = parse('> [!note]\n> body');
|
|
41
|
+
const bq = findBlockquote(tree);
|
|
42
|
+
const data = bq?.data as { hProperties?: Record<string, unknown> } | undefined;
|
|
43
|
+
expect(data?.hProperties?.['data-alert-type']).toBe('note');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('does not transform [!UNKNOWN] blockquotes — they stay as plain blockquotes', () => {
|
|
47
|
+
const tree = parse('> [!UNKNOWN]\n> body');
|
|
48
|
+
const bq = findBlockquote(tree);
|
|
49
|
+
expect(bq?.data).toBeUndefined();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('does not transform plain blockquotes without a marker', () => {
|
|
53
|
+
const tree = parse('> just a quote\n> with two lines');
|
|
54
|
+
const bq = findBlockquote(tree);
|
|
55
|
+
expect(bq?.data).toBeUndefined();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('strips the marker token from the body content', () => {
|
|
59
|
+
const tree = parse('> [!NOTE]\n> the surviving body');
|
|
60
|
+
const bq = findBlockquote(tree);
|
|
61
|
+
// After the plugin runs, the marker should be gone from the first text node.
|
|
62
|
+
const first = bq?.children[0];
|
|
63
|
+
if (first?.type !== 'paragraph') throw new Error('expected paragraph');
|
|
64
|
+
const text = first.children[0];
|
|
65
|
+
if (text?.type !== 'text') throw new Error('expected text');
|
|
66
|
+
expect(text.value).not.toMatch(/\[!NOTE\]/);
|
|
67
|
+
expect(text.value).toContain('the surviving body');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('handles inline content on the same line as the marker', () => {
|
|
71
|
+
// GitHub's spec technically wants [!TYPE] on its own line, but some authors
|
|
72
|
+
// write `> [!NOTE] body inline`. We accept it.
|
|
73
|
+
const tree = parse('> [!NOTE] inline body');
|
|
74
|
+
const bq = findBlockquote(tree);
|
|
75
|
+
const data = bq?.data as { hProperties?: Record<string, unknown> } | undefined;
|
|
76
|
+
expect(data?.hProperties?.['data-alert-type']).toBe('note');
|
|
77
|
+
const first = bq?.children[0];
|
|
78
|
+
if (first?.type !== 'paragraph') throw new Error('expected paragraph');
|
|
79
|
+
const text = first.children[0];
|
|
80
|
+
if (text?.type !== 'text') throw new Error('expected text');
|
|
81
|
+
expect(text.value).toContain('inline body');
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { visit } from 'unist-util-visit';
|
|
2
|
+
import type { Root, Blockquote, Paragraph, Text } from 'mdast';
|
|
3
|
+
|
|
4
|
+
const ALERT_TYPES = ['note', 'tip', 'important', 'warning', 'caution'] as const;
|
|
5
|
+
type AlertType = (typeof ALERT_TYPES)[number];
|
|
6
|
+
|
|
7
|
+
// Match `[!TYPE]` (case-insensitive per GitHub) at the start of the first text
|
|
8
|
+
// node, optionally followed by inline content on the same line.
|
|
9
|
+
const MARKER_RE = /^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\][ \t]*/i;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Transforms `> [!NOTE]` / `> [!TIP]` / `> [!IMPORTANT]` / `> [!WARNING]` /
|
|
13
|
+
* `> [!CAUTION]` blockquotes (GitHub-flavored alerts) into a custom hast
|
|
14
|
+
* element `<github-alert data-alert-type="note">` whose children are the
|
|
15
|
+
* remaining blockquote content. `remark-gfm` v4 does not include this
|
|
16
|
+
* transform — alerts pass through as plain blockquotes without this plugin.
|
|
17
|
+
*
|
|
18
|
+
* The component override for `github-alert` is `<GithubAlert>`, which renders
|
|
19
|
+
* the styled callout with an icon, title, and body.
|
|
20
|
+
*/
|
|
21
|
+
export default function remarkGithubAlerts() {
|
|
22
|
+
return (tree: Root) => {
|
|
23
|
+
visit(tree, 'blockquote', (node: Blockquote) => {
|
|
24
|
+
if (node.children.length === 0) return;
|
|
25
|
+
const firstBlock = node.children[0];
|
|
26
|
+
if (firstBlock.type !== 'paragraph') return;
|
|
27
|
+
const paragraph = firstBlock as Paragraph;
|
|
28
|
+
if (paragraph.children.length === 0) return;
|
|
29
|
+
const firstText = paragraph.children[0];
|
|
30
|
+
if (firstText.type !== 'text') return;
|
|
31
|
+
|
|
32
|
+
const text = firstText as Text;
|
|
33
|
+
const match = text.value.match(MARKER_RE);
|
|
34
|
+
if (!match) return;
|
|
35
|
+
|
|
36
|
+
const type = match[1].toLowerCase() as AlertType;
|
|
37
|
+
// Strip the marker token from the first text node. If the rest of that
|
|
38
|
+
// text node was just the marker (now empty), shift it out AND drop any
|
|
39
|
+
// immediately-following soft-break so the body doesn't start with a
|
|
40
|
+
// blank line.
|
|
41
|
+
const trailing = text.value.slice(match[0].length).replace(/^\n+/, '');
|
|
42
|
+
if (trailing) {
|
|
43
|
+
text.value = trailing;
|
|
44
|
+
} else {
|
|
45
|
+
paragraph.children.shift();
|
|
46
|
+
if (paragraph.children[0]?.type === 'break') {
|
|
47
|
+
paragraph.children.shift();
|
|
48
|
+
}
|
|
49
|
+
// If the first paragraph is now entirely empty, drop it altogether.
|
|
50
|
+
if (paragraph.children.length === 0) {
|
|
51
|
+
node.children.shift();
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
node.data = node.data ?? {};
|
|
56
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
57
|
+
(node.data as any).hName = 'github-alert';
|
|
58
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
59
|
+
(node.data as any).hProperties = { 'data-alert-type': type };
|
|
60
|
+
});
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export { ALERT_TYPES };
|
|
65
|
+
export type { AlertType };
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { visit } from 'unist-util-visit';
|
|
2
|
+
import type { Root } from 'mdast';
|
|
3
|
+
|
|
4
|
+
const CONTAINER_OPENER_RE = /^:::[ \t]+([a-zA-Z][\w-]*)(?:[ \t]+([^\n]+))?[ \t]*$/;
|
|
5
|
+
const FENCE_OPEN_RE = /^[ \t]*(`{3,}|~{3,})/;
|
|
6
|
+
|
|
7
|
+
function normalizeContainerLine(line: string): string {
|
|
8
|
+
return line.replace(
|
|
9
|
+
CONTAINER_OPENER_RE,
|
|
10
|
+
(_match, name: string, label: string | undefined) =>
|
|
11
|
+
label ? `:::${name}[${label.trim()}]` : `:::${name}`,
|
|
12
|
+
);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Pre-process VuePress's relaxed container opener (`::: name [optional title]`)
|
|
17
|
+
* into remark-directive's canonical form (`:::name[title]`). The Markdown spec
|
|
18
|
+
* variant remark-directive recognizes is space-less; the dmla source — and
|
|
19
|
+
* VuePress in general — uses a space-after-colons style with an inline title.
|
|
20
|
+
*
|
|
21
|
+
* Skips fenced code blocks (`` ``` `` / `~~~`) so a documentation example that
|
|
22
|
+
* shows VuePress container syntax verbatim doesn't get rewritten as if it were
|
|
23
|
+
* the syntax itself. The fence tracker is character-type-aware: a `~~~` fence
|
|
24
|
+
* isn't closed by `` ``` `` and vice-versa, matching CommonMark.
|
|
25
|
+
*
|
|
26
|
+
* Runs as a string-level pass before the AST is built, so the existing
|
|
27
|
+
* `:::code-group` usage already in this repo (no space) is unaffected.
|
|
28
|
+
*/
|
|
29
|
+
export function normalizeVuepressContainerSyntax(source: string): string {
|
|
30
|
+
const lines = source.split('\n');
|
|
31
|
+
const out: string[] = [];
|
|
32
|
+
let openFence: string | null = null;
|
|
33
|
+
|
|
34
|
+
for (const line of lines) {
|
|
35
|
+
if (openFence === null) {
|
|
36
|
+
const openMatch = line.match(FENCE_OPEN_RE);
|
|
37
|
+
if (openMatch) {
|
|
38
|
+
openFence = openMatch[1];
|
|
39
|
+
out.push(line);
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
out.push(normalizeContainerLine(line));
|
|
43
|
+
} else {
|
|
44
|
+
// A closing fence is one with the same character type and at least as
|
|
45
|
+
// many characters as the opener, optionally indented, with nothing after.
|
|
46
|
+
const closeRe = new RegExp(`^[ \\t]*${openFence[0]}{${openFence.length},}\\s*$`);
|
|
47
|
+
if (closeRe.test(line)) openFence = null;
|
|
48
|
+
out.push(line);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return out.join('\n');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// VuePress container names → GitHub-flavored alert types. Maps the four
|
|
55
|
+
// container types the dmla source uses (note/tip/warning/danger). `info` and
|
|
56
|
+
// `caution` are accepted as VuePress synonyms; `danger` rewrites to GitHub's
|
|
57
|
+
// `caution` since GitHub doesn't have a `danger` variant.
|
|
58
|
+
const CONTAINER_TO_ALERT: Record<string, string> = {
|
|
59
|
+
note: 'note',
|
|
60
|
+
info: 'note',
|
|
61
|
+
tip: 'tip',
|
|
62
|
+
important: 'important',
|
|
63
|
+
warning: 'warning',
|
|
64
|
+
caution: 'caution',
|
|
65
|
+
danger: 'caution',
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
interface DirectiveLike {
|
|
69
|
+
type: string;
|
|
70
|
+
name?: string;
|
|
71
|
+
attributes?: Record<string, string | undefined> | null;
|
|
72
|
+
children?: unknown[];
|
|
73
|
+
data?: { hName?: string; hProperties?: Record<string, unknown> };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
interface DirectiveLabelNode {
|
|
77
|
+
type: 'paragraph';
|
|
78
|
+
data?: { directiveLabel?: boolean };
|
|
79
|
+
children: Array<{ type: string; value?: string }>;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Transforms VuePress-style container directives (`:::note`, `:::tip`,
|
|
84
|
+
* `:::warning`, `:::danger`, `:::info`) into the same custom hast element
|
|
85
|
+
* (`<github-alert data-alert-type="..." data-alert-title="...">`) that
|
|
86
|
+
* `remark-github-alerts` emits. Keeping a single component for both syntaxes
|
|
87
|
+
* means the renderer doesn't need to learn a second callout shape.
|
|
88
|
+
*
|
|
89
|
+
* `remark-directive` must run before this plugin so the `containerDirective`
|
|
90
|
+
* nodes exist in the tree.
|
|
91
|
+
*
|
|
92
|
+
* A custom title (e.g. `:::tip 智慧的疆界`) is preserved on the
|
|
93
|
+
* `data-alert-title` attribute. The remark-directive parser surfaces the
|
|
94
|
+
* label as the first child paragraph with `data.directiveLabel === true`.
|
|
95
|
+
*/
|
|
96
|
+
export default function remarkVuepressContainers() {
|
|
97
|
+
return (tree: Root) => {
|
|
98
|
+
visit(tree, (node: unknown) => {
|
|
99
|
+
const directive = node as DirectiveLike;
|
|
100
|
+
if (directive.type !== 'containerDirective') return;
|
|
101
|
+
const name = directive.name?.toLowerCase();
|
|
102
|
+
if (!name || !(name in CONTAINER_TO_ALERT)) return;
|
|
103
|
+
|
|
104
|
+
// Extract an optional title from the first child paragraph marked as
|
|
105
|
+
// the directive label. (remark-directive puts `data.directiveLabel: true`
|
|
106
|
+
// on the synthetic paragraph it builds from text following the directive
|
|
107
|
+
// name on the opening line.)
|
|
108
|
+
let title: string | undefined;
|
|
109
|
+
if (directive.children && directive.children.length > 0) {
|
|
110
|
+
const first = directive.children[0] as DirectiveLabelNode;
|
|
111
|
+
if (first?.type === 'paragraph' && first.data?.directiveLabel) {
|
|
112
|
+
title = first.children
|
|
113
|
+
.filter(c => c.type === 'text')
|
|
114
|
+
.map(c => c.value ?? '')
|
|
115
|
+
.join('')
|
|
116
|
+
.trim() || undefined;
|
|
117
|
+
directive.children.shift();
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
directive.data = directive.data ?? {};
|
|
122
|
+
directive.data.hName = 'github-alert';
|
|
123
|
+
const hProperties: Record<string, unknown> = {
|
|
124
|
+
'data-alert-type': CONTAINER_TO_ALERT[name],
|
|
125
|
+
};
|
|
126
|
+
if (title) hProperties['data-alert-title'] = title;
|
|
127
|
+
directive.data.hProperties = hProperties;
|
|
128
|
+
});
|
|
129
|
+
};
|
|
130
|
+
}
|