@hutusi/amytis 1.15.0 → 1.17.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/.claude/rules/immersive-reading.md +21 -0
- package/.claude/rules/rst.md +13 -0
- package/CHANGELOG.md +42 -0
- package/CLAUDE.md +89 -219
- package/bun.lock +185 -547
- package/content/books/sample-book/index.mdx +3 -0
- package/content/posts/code-block-features-showcase.mdx +223 -0
- package/docs/ALERTS.md +112 -0
- package/docs/ARCHITECTURE.md +298 -5
- package/docs/CODE-BLOCKS.md +238 -0
- package/docs/CONTRIBUTING.md +25 -0
- package/docs/DIGITAL_GARDEN.md +1 -1
- package/docs/guides/README.md +11 -0
- package/docs/guides/importing-vuepress-books.md +237 -0
- package/eslint.config.mjs +18 -6
- package/package.json +42 -20
- package/scripts/generate-code-group-icons.ts +79 -0
- package/scripts/render-rst.py +207 -3
- package/scripts/sync-vuepress-book.ts +710 -0
- package/site.config.example.ts +3 -3
- package/site.config.ts +3 -3
- package/src/app/[slug]/layout.tsx +30 -0
- package/src/app/books/[slug]/{[chapter] → [...chapter]}/page.tsx +32 -10
- package/src/app/books/[slug]/layout.tsx +24 -0
- package/src/app/books/[slug]/page.tsx +85 -34
- package/src/app/globals.css +570 -123
- package/src/app/page.tsx +7 -1
- package/src/app/posts/layout.tsx +20 -0
- package/src/app/series/[slug]/page.tsx +33 -9
- package/src/app/sitemap.ts +3 -3
- package/src/components/ArticleCopyCleaner.tsx +64 -0
- package/src/components/BookMobileNav.tsx +44 -50
- package/src/components/BookReadingShell.tsx +145 -0
- 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 +1 -0
- package/src/components/CuratedSeriesSection.tsx +28 -10
- package/src/components/ExternalLinkIcon.tsx +15 -0
- package/src/components/FeaturedStoriesSection.tsx +44 -23
- package/src/components/Footer.tsx +1 -1
- package/src/components/GithubAlert.tsx +97 -0
- package/src/components/ImmersiveReader.tsx +130 -0
- package/src/components/ImmersiveReaderTopBar.tsx +106 -0
- package/src/components/ImmersiveReadingFlagHandler.tsx +40 -0
- package/src/components/ImmersiveReadingPrefsPopover.tsx +249 -0
- package/src/components/ImmersiveReadingProvider.tsx +168 -0
- package/src/components/ImmersiveSeriesSidebar.tsx +143 -0
- package/src/components/ImmersiveToggleButton.tsx +45 -0
- package/src/components/MarkdownRenderer.test.tsx +14 -4
- package/src/components/MarkdownRenderer.tsx +175 -23
- package/src/components/Mermaid.tsx +32 -1
- package/src/components/Navbar.tsx +3 -1
- package/src/components/PostList.tsx +1 -1
- package/src/components/PostNavigation.tsx +13 -2
- package/src/components/PostReadingShell.tsx +68 -0
- package/src/components/PostSidebar.tsx +13 -2
- package/src/components/ReadingProgressBar.tsx +1 -1
- package/src/components/RstRenderer.test.tsx +15 -15
- package/src/components/RstRenderer.tsx +37 -2
- package/src/components/Search.tsx +18 -4
- package/src/components/SelectedBooksSection.tsx +27 -8
- package/src/components/SeriesCatalog.tsx +1 -1
- package/src/components/ShareBar.tsx +5 -0
- package/src/components/TocPanel.tsx +10 -2
- package/src/hooks/useActiveHeading.ts +35 -13
- package/src/hooks/useSidebarAutoScroll.ts +31 -7
- package/src/i18n/translations.ts +44 -0
- package/src/layouts/BookLayout.tsx +62 -74
- package/src/layouts/PostLayout.tsx +154 -111
- package/src/lib/code-group-icons.test.ts +78 -0
- package/src/lib/code-group-icons.ts +148 -0
- package/src/lib/immersive-reading-prefs.ts +104 -0
- package/src/lib/markdown.test.ts +56 -13
- package/src/lib/markdown.ts +217 -57
- package/src/lib/normalize-vuepress-math.ts +118 -0
- package/src/lib/rehype-fence-meta.ts +22 -0
- 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.ts +19 -7
- package/src/lib/rst.test.ts +212 -2
- package/src/lib/rst.ts +217 -13
- package/src/lib/scroll-utils.ts +44 -6
- 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/shuffle.ts +15 -1
- package/src/lib/sort.ts +15 -0
- package/src/lib/urls.ts +62 -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/book-index-cta.test.ts +87 -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/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 +8 -6
- package/tests/integration/series-draft.test.ts +6 -13
- package/tests/integration/series-index-cta.test.ts +88 -0
- package/tests/integration/sync-vuepress-book.test.ts +443 -0
- package/tests/integration/vuepress-containers.test.ts +107 -0
- package/tests/tooling/new-post.test.ts +1 -1
- package/tests/unit/immersive-reading-prefs.test.ts +144 -0
- package/tests/unit/static-params.test.ts +32 -19
- package/vercel.json +7 -0
|
@@ -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
|
+
}
|
package/src/lib/rst-renderer.ts
CHANGED
|
@@ -34,7 +34,8 @@ export interface RenderedRstDocument {
|
|
|
34
34
|
headings: PythonRstHeading[];
|
|
35
35
|
metadata: RstMetadata;
|
|
36
36
|
excerpt: string;
|
|
37
|
-
|
|
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
|
-
|
|
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
|
|
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]+(?:['
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
};
|