@hutusi/amytis 1.15.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 (87) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/CLAUDE.md +90 -219
  3. package/bun.lock +185 -547
  4. package/content/books/sample-book/index.mdx +3 -0
  5. package/content/posts/code-block-features-showcase.mdx +223 -0
  6. package/docs/ALERTS.md +112 -0
  7. package/docs/ARCHITECTURE.md +217 -5
  8. package/docs/CODE-BLOCKS.md +238 -0
  9. package/docs/CONTRIBUTING.md +25 -0
  10. package/docs/guides/README.md +11 -0
  11. package/docs/guides/importing-vuepress-books.md +178 -0
  12. package/eslint.config.mjs +18 -6
  13. package/package.json +42 -20
  14. package/scripts/generate-code-group-icons.ts +79 -0
  15. package/scripts/render-rst.py +207 -3
  16. package/scripts/sync-vuepress-book.ts +499 -0
  17. package/src/app/books/[slug]/{[chapter] → [...chapter]}/page.tsx +32 -10
  18. package/src/app/books/[slug]/page.tsx +67 -32
  19. package/src/app/globals.css +503 -123
  20. package/src/app/page.tsx +1 -1
  21. package/src/app/sitemap.ts +3 -3
  22. package/src/components/ArticleCopyCleaner.tsx +64 -0
  23. package/src/components/BookMobileNav.tsx +44 -50
  24. package/src/components/BookSidebar.tsx +0 -0
  25. package/src/components/CodeBlock.test.tsx +93 -8
  26. package/src/components/CodeBlock.tsx +39 -101
  27. package/src/components/CodeBlockToolbar.tsx +88 -0
  28. package/src/components/CodeGroup.tsx +81 -0
  29. package/src/components/CoverImage.tsx +1 -0
  30. package/src/components/ExternalLinkIcon.tsx +15 -0
  31. package/src/components/FeaturedStoriesSection.tsx +3 -3
  32. package/src/components/GithubAlert.tsx +97 -0
  33. package/src/components/MarkdownRenderer.test.tsx +14 -4
  34. package/src/components/MarkdownRenderer.tsx +144 -23
  35. package/src/components/Mermaid.tsx +32 -1
  36. package/src/components/PostList.tsx +1 -1
  37. package/src/components/PostNavigation.tsx +13 -2
  38. package/src/components/PostSidebar.tsx +13 -2
  39. package/src/components/RstRenderer.test.tsx +15 -15
  40. package/src/components/RstRenderer.tsx +37 -2
  41. package/src/components/Search.tsx +18 -4
  42. package/src/components/SeriesCatalog.tsx +1 -1
  43. package/src/components/ShareBar.tsx +5 -0
  44. package/src/components/TocPanel.tsx +10 -2
  45. package/src/i18n/translations.ts +2 -0
  46. package/src/layouts/BookLayout.tsx +35 -4
  47. package/src/layouts/PostLayout.tsx +5 -1
  48. package/src/lib/code-group-icons.test.ts +78 -0
  49. package/src/lib/code-group-icons.ts +148 -0
  50. package/src/lib/markdown.test.ts +56 -13
  51. package/src/lib/markdown.ts +203 -50
  52. package/src/lib/normalize-vuepress-math.ts +118 -0
  53. package/src/lib/rehype-fence-meta.ts +22 -0
  54. package/src/lib/remark-book-chapter-links.ts +106 -0
  55. package/src/lib/remark-code-group.ts +54 -0
  56. package/src/lib/remark-github-alerts.test.ts +83 -0
  57. package/src/lib/remark-github-alerts.ts +65 -0
  58. package/src/lib/remark-vuepress-containers.ts +130 -0
  59. package/src/lib/rst-renderer.ts +19 -7
  60. package/src/lib/rst.test.ts +212 -2
  61. package/src/lib/rst.ts +217 -13
  62. package/src/lib/shiki-rst.ts +185 -0
  63. package/src/lib/shiki.test.ts +153 -0
  64. package/src/lib/shiki.ts +292 -0
  65. package/src/lib/urls.ts +57 -0
  66. package/src/test-utils/render.ts +23 -0
  67. package/tests/fixtures/sync-vuepress-book/docs/.vuepress/config.js +43 -0
  68. package/tests/fixtures/sync-vuepress-book/docs/intro/welcome.md +7 -0
  69. package/tests/fixtures/sync-vuepress-book/docs/maths/linear/assets/diagram.png +1 -0
  70. package/tests/fixtures/sync-vuepress-book/docs/maths/linear/matrices.md +7 -0
  71. package/tests/fixtures/sync-vuepress-book/docs/maths/linear/vectors.md +9 -0
  72. package/tests/helpers/env.ts +19 -0
  73. package/tests/integration/book-chapter-links.test.ts +107 -0
  74. package/tests/integration/books-nested-toc.test.ts +176 -0
  75. package/tests/integration/books.test.ts +3 -2
  76. package/tests/integration/code-block-features.test.ts +188 -0
  77. package/tests/integration/code-group.test.ts +183 -0
  78. package/tests/integration/code-notation.test.ts +97 -0
  79. package/tests/integration/github-alerts.test.ts +82 -0
  80. package/tests/integration/markdown-external-links.test.ts +103 -0
  81. package/tests/integration/normalize-vuepress-math.test.ts +149 -0
  82. package/tests/integration/reading-time-headings.test.ts +8 -6
  83. package/tests/integration/series-draft.test.ts +6 -13
  84. package/tests/integration/sync-vuepress-book.test.ts +240 -0
  85. package/tests/integration/vuepress-containers.test.ts +107 -0
  86. package/tests/tooling/new-post.test.ts +1 -1
  87. package/tests/unit/static-params.test.ts +32 -19
@@ -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
+ }
@@ -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
+ }
@@ -34,7 +34,8 @@ export interface RenderedRstDocument {
34
34
  headings: PythonRstHeading[];
35
35
  metadata: RstMetadata;
36
36
  excerpt: string;
37
- readingTime: string;
37
+ readingMinutes: number;
38
+ wordCount: number;
38
39
  assets: PythonRstAsset[];
39
40
  warnings: string[];
40
41
  }
@@ -67,7 +68,10 @@ interface RstRendererDiskCacheEntry {
67
68
 
68
69
  const rstRenderCache = new Map<string, RenderedRstDocument>();
69
70
  const PYTHON_RENDERER_MAX_BUFFER = 1024 * 1024 * 128;
70
- const RST_RENDERER_DISK_CACHE_VERSION = '1';
71
+ // Bumped when the docutils renderer's HTML output shape changes:
72
+ // v2 emitted <pre data-amytis-code> markers; v3 additionally emits
73
+ // <div data-amytis-code-group> wrappers around .. code-group:: nests.
74
+ const RST_RENDERER_DISK_CACHE_VERSION = '3';
71
75
  const rstRendererCacheDir = path.join(process.cwd(), '.cache', 'rst-renderer');
72
76
  let resolvedPythonCommandSpec: PythonCommandSpec | null = null;
73
77
  let pythonRendererInvocationCount = 0;
@@ -345,15 +349,21 @@ export function normalizePythonRstMetadata(metadata: Record<string, unknown>): R
345
349
  return normalized;
346
350
  }
347
351
 
348
- function calculateReadingTimeFromText(text: string): string {
352
+ function calculateReadingMinutesFromText(text: string): number {
349
353
  const wordsPerMinute = 200;
350
354
  const hanCharsPerMinute = 300;
351
355
 
352
356
  const hanCharCount = (text.match(/[\u3400-\u4DBF\u4E00-\u9FFF\uF900-\uFAFF]/g) || []).length;
353
- const latinWordCount = (text.match(/[A-Za-z0-9]+(?:['’-][A-Za-z0-9]+)*/g) || []).length;
357
+ const latinWordCount = (text.match(/[A-Za-z0-9]+(?:['\u2019-][A-Za-z0-9]+)*/g) || []).length;
354
358
 
355
359
  const estimatedMinutes = (latinWordCount / wordsPerMinute) + (hanCharCount / hanCharsPerMinute);
356
- return `${Math.max(1, Math.ceil(estimatedMinutes))} min read`;
360
+ return Math.max(1, Math.ceil(estimatedMinutes));
361
+ }
362
+
363
+ function calculateWordCountFromText(text: string): number {
364
+ const hanCharCount = (text.match(/[\u3400-\u4DBF\u4E00-\u9FFF\uF900-\uFAFF]/g) || []).length;
365
+ const latinWordCount = (text.match(/[A-Za-z0-9]+(?:['\u2019-][A-Za-z0-9]+)*/g) || []).length;
366
+ return latinWordCount + hanCharCount;
357
367
  }
358
368
 
359
369
  export function validatePythonRstResult(result: PythonRstRenderResult, filePath: string): void {
@@ -554,7 +564,8 @@ export function renderRstFile(filePath: string, imageBaseSlug: string): Rendered
554
564
  headings: result.headings,
555
565
  metadata,
556
566
  excerpt: metadata.excerpt ?? '',
557
- readingTime: calculateReadingTimeFromText(result.text),
567
+ readingMinutes: calculateReadingMinutesFromText(result.text),
568
+ wordCount: calculateWordCountFromText(result.text),
558
569
  assets: result.assets ?? [],
559
570
  warnings: (result.warnings ?? []).map((warning) => String(warning)),
560
571
  };
@@ -604,7 +615,8 @@ export function renderRstFilesBatch(entries: PythonRstBatchEntry[]): Map<string,
604
615
  headings: result.headings,
605
616
  metadata,
606
617
  excerpt: metadata.excerpt ?? '',
607
- readingTime: calculateReadingTimeFromText(result.text),
618
+ readingMinutes: calculateReadingMinutesFromText(result.text),
619
+ wordCount: calculateWordCountFromText(result.text),
608
620
  assets: result.assets ?? [],
609
621
  warnings: (result.warnings ?? []).map((warning) => String(warning)),
610
622
  };