@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.
Files changed (128) hide show
  1. package/.github/workflows/ci.yml +1 -1
  2. package/.github/workflows/publish.yml +2 -2
  3. package/CHANGELOG.md +42 -0
  4. package/CLAUDE.md +90 -219
  5. package/README.md +33 -1
  6. package/README.zh.md +33 -1
  7. package/TODO.md +10 -0
  8. package/bun.lock +205 -539
  9. package/content/books/sample-book/index.mdx +3 -0
  10. package/content/posts/code-block-features-showcase.mdx +223 -0
  11. package/content/series/rst-legacy/deeper-notes/images/test.svg +4 -0
  12. package/content/series/rst-legacy/deeper-notes/index.rst +15 -0
  13. package/content/series/rst-legacy/getting-started.rst +24 -0
  14. package/content/series/rst-legacy/index.rst +9 -0
  15. package/content/series/rst-readme/README.rst +9 -0
  16. package/content/series/rst-readme/readme-index-post.rst +10 -0
  17. package/content/series/rst-toctree/first-post.rst +6 -0
  18. package/content/series/rst-toctree/index.rst +10 -0
  19. package/content/series/rst-toctree/second-post.rst +6 -0
  20. package/content/series/rst-toctree-precedence/first-post.rst +6 -0
  21. package/content/series/rst-toctree-precedence/index.rst +12 -0
  22. package/content/series/rst-toctree-precedence/second-post.rst +6 -0
  23. package/docs/ALERTS.md +112 -0
  24. package/docs/ARCHITECTURE.md +239 -8
  25. package/docs/CODE-BLOCKS.md +238 -0
  26. package/docs/CONTRIBUTING.md +36 -0
  27. package/docs/guides/README.md +11 -0
  28. package/docs/guides/importing-vuepress-books.md +178 -0
  29. package/eslint.config.mjs +20 -6
  30. package/next.config.ts +2 -2
  31. package/package.json +52 -24
  32. package/packages/create-amytis/package.json +1 -1
  33. package/packages/create-amytis/src/index.test.ts +43 -1
  34. package/packages/create-amytis/src/index.ts +64 -8
  35. package/public/next-image-export-optimizer-hashes.json +14 -73
  36. package/scripts/build-pagefind.ts +172 -0
  37. package/scripts/copy-assets.ts +246 -56
  38. package/scripts/generate-code-group-icons.ts +79 -0
  39. package/scripts/generate-knowledge-graph.ts +2 -1
  40. package/scripts/render-rst.py +923 -0
  41. package/scripts/run-with-rst-python.ts +42 -0
  42. package/scripts/sync-vuepress-book.ts +499 -0
  43. package/src/app/[slug]/[postSlug]/page.tsx +20 -10
  44. package/src/app/[slug]/page/[page]/page.tsx +15 -0
  45. package/src/app/books/[slug]/{[chapter] → [...chapter]}/page.tsx +32 -10
  46. package/src/app/books/[slug]/page.tsx +67 -32
  47. package/src/app/globals.css +639 -94
  48. package/src/app/page.tsx +1 -1
  49. package/src/app/series/[slug]/page/[page]/page.tsx +74 -6
  50. package/src/app/series/[slug]/page.tsx +11 -13
  51. package/src/app/series/page.tsx +3 -3
  52. package/src/app/sitemap.ts +3 -3
  53. package/src/components/ArticleCopyCleaner.tsx +64 -0
  54. package/src/components/AuthorCard.tsx +25 -16
  55. package/src/components/BookMobileNav.tsx +44 -50
  56. package/src/components/BookSidebar.tsx +0 -0
  57. package/src/components/CodeBlock.test.tsx +93 -8
  58. package/src/components/CodeBlock.tsx +39 -101
  59. package/src/components/CodeBlockToolbar.tsx +88 -0
  60. package/src/components/CodeGroup.tsx +81 -0
  61. package/src/components/CoverImage.tsx +6 -2
  62. package/src/components/ExternalLinkIcon.tsx +15 -0
  63. package/src/components/FeaturedStoriesSection.tsx +3 -3
  64. package/src/components/GithubAlert.tsx +97 -0
  65. package/src/components/MarkdownRenderer.test.tsx +30 -4
  66. package/src/components/MarkdownRenderer.tsx +148 -24
  67. package/src/components/Mermaid.tsx +32 -1
  68. package/src/components/PostList.tsx +1 -1
  69. package/src/components/PostNavigation.tsx +13 -2
  70. package/src/components/PostSidebar.tsx +13 -2
  71. package/src/components/RstRenderer.test.tsx +93 -0
  72. package/src/components/RstRenderer.tsx +157 -0
  73. package/src/components/Search.tsx +18 -4
  74. package/src/components/SeriesCatalog.tsx +1 -1
  75. package/src/components/ShareBar.tsx +5 -0
  76. package/src/components/TocPanel.tsx +10 -2
  77. package/src/i18n/translations.ts +2 -0
  78. package/src/layouts/BookLayout.tsx +35 -4
  79. package/src/layouts/PostLayout.tsx +10 -2
  80. package/src/layouts/SimpleLayout.tsx +10 -3
  81. package/src/lib/code-group-icons.test.ts +78 -0
  82. package/src/lib/code-group-icons.ts +148 -0
  83. package/src/lib/image-utils.test.ts +19 -0
  84. package/src/lib/image-utils.ts +11 -0
  85. package/src/lib/markdown.test.ts +195 -14
  86. package/src/lib/markdown.ts +928 -254
  87. package/src/lib/normalize-vuepress-math.ts +118 -0
  88. package/src/lib/rehype-fence-meta.ts +22 -0
  89. package/src/lib/rehype-image-metadata.ts +2 -2
  90. package/src/lib/remark-book-chapter-links.ts +106 -0
  91. package/src/lib/remark-code-group.ts +54 -0
  92. package/src/lib/remark-github-alerts.test.ts +83 -0
  93. package/src/lib/remark-github-alerts.ts +65 -0
  94. package/src/lib/remark-vuepress-containers.ts +130 -0
  95. package/src/lib/rst-renderer.test.ts +355 -0
  96. package/src/lib/rst-renderer.ts +629 -0
  97. package/src/lib/rst.test.ts +350 -0
  98. package/src/lib/rst.ts +674 -0
  99. package/src/lib/series-redirects.ts +42 -0
  100. package/src/lib/shiki-rst.ts +185 -0
  101. package/src/lib/shiki.test.ts +153 -0
  102. package/src/lib/shiki.ts +292 -0
  103. package/src/lib/urls.ts +57 -0
  104. package/src/test-utils/render.ts +23 -0
  105. package/tests/fixtures/sync-vuepress-book/docs/.vuepress/config.js +43 -0
  106. package/tests/fixtures/sync-vuepress-book/docs/intro/welcome.md +7 -0
  107. package/tests/fixtures/sync-vuepress-book/docs/maths/linear/assets/diagram.png +1 -0
  108. package/tests/fixtures/sync-vuepress-book/docs/maths/linear/matrices.md +7 -0
  109. package/tests/fixtures/sync-vuepress-book/docs/maths/linear/vectors.md +9 -0
  110. package/tests/helpers/env.ts +19 -0
  111. package/tests/integration/book-chapter-links.test.ts +107 -0
  112. package/tests/integration/books-nested-toc.test.ts +176 -0
  113. package/tests/integration/books.test.ts +3 -2
  114. package/tests/integration/code-block-features.test.ts +188 -0
  115. package/tests/integration/code-group.test.ts +183 -0
  116. package/tests/integration/code-notation.test.ts +97 -0
  117. package/tests/integration/feed-utils.test.ts +13 -0
  118. package/tests/integration/github-alerts.test.ts +82 -0
  119. package/tests/integration/markdown-external-links.test.ts +103 -0
  120. package/tests/integration/normalize-vuepress-math.test.ts +149 -0
  121. package/tests/integration/reading-time-headings.test.ts +12 -14
  122. package/tests/integration/series-draft.test.ts +12 -5
  123. package/tests/integration/series.test.ts +93 -0
  124. package/tests/integration/sync-vuepress-book.test.ts +240 -0
  125. package/tests/integration/vuepress-containers.test.ts +107 -0
  126. package/tests/tooling/build-pagefind.test.ts +66 -0
  127. package/tests/tooling/new-post.test.ts +1 -1
  128. 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
+ }