@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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hutusi/amytis",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.17.0",
|
|
4
4
|
"description": "A high-performance digital garden and blog engine with Next.js 16 and Tailwind CSS v4",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
"import-obsidian": "bun scripts/import-obsidian.ts",
|
|
35
35
|
"import-book": "bun scripts/import-book.ts",
|
|
36
36
|
"sync-book": "bun scripts/sync-book-chapters.ts",
|
|
37
|
+
"sync-vuepress-book": "bun scripts/sync-vuepress-book.ts",
|
|
37
38
|
"series-draft": "bun scripts/series-draft.ts",
|
|
38
39
|
"add-series-redirects": "bun scripts/add-series-redirects.ts",
|
|
39
40
|
"deploy": "bun scripts/deploy.ts",
|
|
@@ -47,54 +48,75 @@
|
|
|
47
48
|
},
|
|
48
49
|
"dependencies": {
|
|
49
50
|
"@giscus/react": "^3.1.0",
|
|
51
|
+
"@shikijs/transformers": "^4.1.0",
|
|
50
52
|
"@tailwindcss/typography": "^0.5.19",
|
|
51
53
|
"d3": "^7.9.0",
|
|
52
54
|
"github-slugger": "^2.0.0",
|
|
53
55
|
"gray-matter": "^4.0.3",
|
|
56
|
+
"hast-util-to-html": "^9.0.5",
|
|
54
57
|
"image-size": "^2.0.2",
|
|
55
|
-
"katex": "^0.16.
|
|
56
|
-
"mermaid": "^11.
|
|
57
|
-
"next": "16.2.
|
|
58
|
+
"katex": "^0.16.47",
|
|
59
|
+
"mermaid": "^11.15.0",
|
|
60
|
+
"next": "16.2.6",
|
|
58
61
|
"next-image-export-optimizer": "^1.20.1",
|
|
59
62
|
"next-themes": "^0.4.6",
|
|
60
|
-
"react": "19.2.
|
|
61
|
-
"react-dom": "19.2.
|
|
63
|
+
"react": "19.2.6",
|
|
64
|
+
"react-dom": "19.2.6",
|
|
62
65
|
"react-icons": "^5.6.0",
|
|
63
66
|
"react-markdown": "^10.1.0",
|
|
64
|
-
"react-syntax-highlighter": "^16.1.1",
|
|
65
67
|
"rehype-katex": "^7.0.1",
|
|
68
|
+
"rehype-parse": "^9.0.1",
|
|
66
69
|
"rehype-raw": "^7.0.0",
|
|
67
70
|
"rehype-slug": "^6.0.0",
|
|
68
71
|
"rehype-stringify": "^10.0.1",
|
|
72
|
+
"remark-directive": "^4.0.0",
|
|
69
73
|
"remark-gfm": "^4.0.1",
|
|
70
74
|
"remark-math": "^6.0.0",
|
|
71
75
|
"remark-parse": "^11.0.0",
|
|
72
76
|
"remark-rehype": "^11.1.2",
|
|
73
|
-
"sanitize-html": "^2.17.
|
|
77
|
+
"sanitize-html": "^2.17.4",
|
|
78
|
+
"shiki": "^4.1.0",
|
|
74
79
|
"unified": "^11.0.5",
|
|
75
80
|
"unist-util-visit": "^5.1.0",
|
|
76
|
-
"zod": "^4.3
|
|
81
|
+
"zod": "^4.4.3"
|
|
77
82
|
},
|
|
78
83
|
"devDependencies": {
|
|
79
|
-
"@
|
|
80
|
-
"@
|
|
81
|
-
"@
|
|
84
|
+
"@iconify-json/logos": "^1.2.11",
|
|
85
|
+
"@iconify-json/vscode-icons": "^1.2.52",
|
|
86
|
+
"@next/eslint-plugin-next": "^16.2.6",
|
|
87
|
+
"@playwright/test": "^1.60.0",
|
|
88
|
+
"@tailwindcss/postcss": "^4.3.0",
|
|
89
|
+
"@types/bun": "^1.3.14",
|
|
82
90
|
"@types/d3": "^7.4.3",
|
|
83
91
|
"@types/hast": "^3.0.4",
|
|
84
92
|
"@types/image-size": "^0.8.0",
|
|
85
93
|
"@types/mdast": "^4.0.4",
|
|
86
|
-
"@types/node": "^
|
|
94
|
+
"@types/node": "^25.8.0",
|
|
87
95
|
"@types/react": "^19.2.14",
|
|
88
96
|
"@types/react-dom": "^19.2.3",
|
|
89
|
-
"@types/react-syntax-highlighter": "^15.5.13",
|
|
90
97
|
"@types/sanitize-html": "^2.16.1",
|
|
98
|
+
"acorn": "^8.16.0",
|
|
91
99
|
"babel-plugin-react-compiler": "1.0.0",
|
|
92
|
-
"eslint": "^
|
|
93
|
-
"eslint-
|
|
94
|
-
"pagefind": "^1.5.
|
|
95
|
-
"pdf-to-img": "^
|
|
96
|
-
"tailwindcss": "^4.
|
|
97
|
-
"typescript": "^
|
|
100
|
+
"eslint": "^10.4.0",
|
|
101
|
+
"eslint-plugin-react-hooks": "^7.1.1",
|
|
102
|
+
"pagefind": "^1.5.2",
|
|
103
|
+
"pdf-to-img": "^6.1.0",
|
|
104
|
+
"tailwindcss": "^4.3.0",
|
|
105
|
+
"typescript": "^6.0.3",
|
|
106
|
+
"typescript-eslint": "^8.59.3"
|
|
107
|
+
},
|
|
108
|
+
"overrides": {
|
|
109
|
+
"@typescript-eslint/eslint-plugin": "^8.59.3",
|
|
110
|
+
"@typescript-eslint/parser": "^8.59.3",
|
|
111
|
+
"@typescript-eslint/project-service": "^8.59.3",
|
|
112
|
+
"@typescript-eslint/scope-manager": "^8.59.3",
|
|
113
|
+
"@typescript-eslint/tsconfig-utils": "^8.59.3",
|
|
114
|
+
"@typescript-eslint/type-utils": "^8.59.3",
|
|
115
|
+
"@typescript-eslint/types": "^8.59.3",
|
|
116
|
+
"@typescript-eslint/typescript-estree": "^8.59.3",
|
|
117
|
+
"@typescript-eslint/utils": "^8.59.3",
|
|
118
|
+
"@typescript-eslint/visitor-keys": "^8.59.3",
|
|
119
|
+
"typescript-eslint": "^8.59.3"
|
|
98
120
|
},
|
|
99
121
|
"ignoreScripts": [
|
|
100
122
|
"sharp",
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One-shot generator: builds CSS rules for the code-group tab icons by reading
|
|
3
|
+
* Iconify's logos / vscode-icons packs and embedding each icon as a data URI.
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* bun scripts/generate-code-group-icons.ts > /tmp/icons.css
|
|
7
|
+
* Then replace the block between the BEGIN/END markers in src/app/globals.css.
|
|
8
|
+
*
|
|
9
|
+
* Each icon key here corresponds to a value returned by resolveCodeGroupIcon
|
|
10
|
+
* in src/lib/code-group-icons.ts. Add to both files to support a new key.
|
|
11
|
+
*/
|
|
12
|
+
import { icons as logos } from '@iconify-json/logos';
|
|
13
|
+
import { icons as vscodeIcons } from '@iconify-json/vscode-icons';
|
|
14
|
+
|
|
15
|
+
type IconSrc = typeof logos | typeof vscodeIcons;
|
|
16
|
+
|
|
17
|
+
const MAP: Record<string, { src: IconSrc; name: string }> = {
|
|
18
|
+
npm: { src: logos, name: 'npm-icon' },
|
|
19
|
+
yarn: { src: logos, name: 'yarn' },
|
|
20
|
+
pnpm: { src: logos, name: 'pnpm' },
|
|
21
|
+
bun: { src: logos, name: 'bun' },
|
|
22
|
+
deno: { src: logos, name: 'deno' },
|
|
23
|
+
typescript: { src: logos, name: 'typescript-icon' },
|
|
24
|
+
javascript: { src: logos, name: 'javascript' },
|
|
25
|
+
python: { src: logos, name: 'python' },
|
|
26
|
+
rust: { src: logos, name: 'rust' },
|
|
27
|
+
go: { src: logos, name: 'go' },
|
|
28
|
+
java: { src: logos, name: 'java' },
|
|
29
|
+
ruby: { src: logos, name: 'ruby' },
|
|
30
|
+
php: { src: logos, name: 'php' },
|
|
31
|
+
c: { src: logos, name: 'c' },
|
|
32
|
+
cpp: { src: logos, name: 'c-plusplus' },
|
|
33
|
+
html: { src: logos, name: 'html-5' },
|
|
34
|
+
css: { src: logos, name: 'css-3' },
|
|
35
|
+
json: { src: vscodeIcons, name: 'file-type-json' },
|
|
36
|
+
yaml: { src: vscodeIcons, name: 'file-type-yaml' },
|
|
37
|
+
markdown: { src: vscodeIcons, name: 'file-type-markdown' },
|
|
38
|
+
bash: { src: logos, name: 'bash-icon' },
|
|
39
|
+
docker: { src: logos, name: 'docker-icon' },
|
|
40
|
+
vite: { src: logos, name: 'vitejs' },
|
|
41
|
+
react: { src: logos, name: 'react' },
|
|
42
|
+
vue: { src: logos, name: 'vue' },
|
|
43
|
+
nextjs: { src: logos, name: 'nextjs-icon' },
|
|
44
|
+
node: { src: logos, name: 'nodejs-icon' },
|
|
45
|
+
tailwind: { src: logos, name: 'tailwindcss-icon' },
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
function buildSvg(src: IconSrc, name: string): string | null {
|
|
49
|
+
const icon = src.icons[name];
|
|
50
|
+
if (!icon) return null;
|
|
51
|
+
const w = icon.width ?? src.width ?? 24;
|
|
52
|
+
const h = icon.height ?? src.height ?? 24;
|
|
53
|
+
// Iconify "body" is the inner content of the <svg> tag; wrap to get a full SVG.
|
|
54
|
+
return `<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 ${w} ${h}'>${icon.body}</svg>`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function encodeDataUri(svg: string): string {
|
|
58
|
+
// CSS data URIs only require `#` and `"` to be encoded; iconify bodies use
|
|
59
|
+
// double-quoted attributes, so swap to single quotes (valid SVG).
|
|
60
|
+
return svg.replace(/"/g, "'").replace(/#/g, '%23').replace(/\n/g, '').replace(/\s+/g, ' ');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const lines: string[] = [
|
|
64
|
+
'/* === BEGIN: generated by scripts/generate-code-group-icons.ts — do not hand-edit === */',
|
|
65
|
+
];
|
|
66
|
+
for (const [key, { src, name }] of Object.entries(MAP)) {
|
|
67
|
+
const svg = buildSvg(src, name);
|
|
68
|
+
if (!svg) {
|
|
69
|
+
console.error(`Missing icon: ${name}`);
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
const dataUri = encodeDataUri(svg);
|
|
73
|
+
lines.push(`.cg-tab[data-cg-icon="${key}"]::before {`);
|
|
74
|
+
lines.push(` content: '';`);
|
|
75
|
+
lines.push(` background-image: url("data:image/svg+xml,${dataUri}");`);
|
|
76
|
+
lines.push(`}`);
|
|
77
|
+
}
|
|
78
|
+
lines.push('/* === END: generated icons === */');
|
|
79
|
+
console.log(lines.join('\n'));
|
package/scripts/render-rst.py
CHANGED
|
@@ -563,6 +563,140 @@ def strip_preamble_nodes(document: Any) -> Any:
|
|
|
563
563
|
return stripped
|
|
564
564
|
|
|
565
565
|
|
|
566
|
+
def _language_from_classes(classes: list[str] | None) -> str:
|
|
567
|
+
"""Recover the source language from a literal_block's class list when the
|
|
568
|
+
explicit `language` attribute is absent. Docutils stores ``.. code-block:: foo``
|
|
569
|
+
as classes=['code', 'foo']; the first class that isn't a docutils-internal
|
|
570
|
+
marker is the language name.
|
|
571
|
+
"""
|
|
572
|
+
if not classes:
|
|
573
|
+
return ""
|
|
574
|
+
for cls in classes:
|
|
575
|
+
if cls not in ("code", "literal-block", "linenos"):
|
|
576
|
+
return cls
|
|
577
|
+
return ""
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def _build_amytis_code_marker(
|
|
581
|
+
text: str,
|
|
582
|
+
language: str,
|
|
583
|
+
highlight_lines: list[int] | None,
|
|
584
|
+
linenos: bool,
|
|
585
|
+
title: str | None,
|
|
586
|
+
) -> str:
|
|
587
|
+
"""Build the opaque <pre data-amytis-code> marker that the JS-side
|
|
588
|
+
Shiki post-processor in src/lib/shiki-rst.ts replaces with highlighted HTML.
|
|
589
|
+
"""
|
|
590
|
+
attrs = ['data-amytis-code=""']
|
|
591
|
+
if language:
|
|
592
|
+
attrs.append(f'data-language="{html.escape(language, quote=True)}"')
|
|
593
|
+
if highlight_lines:
|
|
594
|
+
attrs.append(
|
|
595
|
+
f'data-highlight-lines="{",".join(str(n) for n in highlight_lines)}"'
|
|
596
|
+
)
|
|
597
|
+
if linenos:
|
|
598
|
+
attrs.append('data-line-numbers="true"')
|
|
599
|
+
if title:
|
|
600
|
+
attrs.append(f'data-title="{html.escape(title, quote=True)}"')
|
|
601
|
+
|
|
602
|
+
escaped = html.escape(text, quote=False)
|
|
603
|
+
return f'<pre {" ".join(attrs)}><code>{escaped}</code></pre>'
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
def _build_inner_block_marker(block: Any) -> tuple[str, str]:
|
|
607
|
+
"""Helper: build the per-block <pre data-amytis-code> marker AND return the
|
|
608
|
+
tab label (from the new `:label:` option, or the language as fallback).
|
|
609
|
+
Used by both the standalone-literal_block path and the code-group path.
|
|
610
|
+
"""
|
|
611
|
+
classes = list(block.get("classes") or [])
|
|
612
|
+
language = block.get("language") or _language_from_classes(classes)
|
|
613
|
+
highlight_args = block.get("highlight_args") or {}
|
|
614
|
+
hl_lines = list(highlight_args.get("hl_lines") or [])
|
|
615
|
+
linenos = "linenos" in classes
|
|
616
|
+
caption_text = block.get("amytis_caption") # set by the directive when :caption: is present
|
|
617
|
+
label = block.get("amytis_label") or language or ""
|
|
618
|
+
|
|
619
|
+
marker = _build_amytis_code_marker(
|
|
620
|
+
text=block.astext(),
|
|
621
|
+
language=language,
|
|
622
|
+
highlight_lines=hl_lines,
|
|
623
|
+
linenos=linenos,
|
|
624
|
+
title=caption_text,
|
|
625
|
+
)
|
|
626
|
+
return marker, label
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
def transform_literal_blocks_to_markers(document: Any) -> None:
|
|
630
|
+
"""Replace every literal_block with an opaque <pre data-amytis-code> marker
|
|
631
|
+
so the JS-side post-processor can run Shiki uniformly. Caption-bearing
|
|
632
|
+
literal-block-wrapper containers are flattened into the marker's data-title.
|
|
633
|
+
|
|
634
|
+
Code-group containers (emitted by the .. code-group:: directive) are
|
|
635
|
+
handled FIRST so their child literal_blocks are consumed before the
|
|
636
|
+
standalone-block pass sees them — otherwise the standalone pass would
|
|
637
|
+
replace them and we'd lose the grouping wrapper.
|
|
638
|
+
"""
|
|
639
|
+
from docutils import nodes
|
|
640
|
+
import json
|
|
641
|
+
|
|
642
|
+
# Pass 1: collapse caption containers so child literal_blocks carry their caption
|
|
643
|
+
# as a custom attribute. (Doing this once here means the helper doesn't need to
|
|
644
|
+
# walk back up to find a parent literal-block-wrapper.)
|
|
645
|
+
for container in list(document.findall(nodes.container)):
|
|
646
|
+
if "literal-block-wrapper" not in (container.get("classes") or []):
|
|
647
|
+
continue
|
|
648
|
+
caption_node = next(
|
|
649
|
+
(c for c in container.children if isinstance(c, nodes.caption)),
|
|
650
|
+
None,
|
|
651
|
+
)
|
|
652
|
+
inner_block = next(
|
|
653
|
+
(c for c in container.children if isinstance(c, nodes.literal_block)),
|
|
654
|
+
None,
|
|
655
|
+
)
|
|
656
|
+
if caption_node is not None and inner_block is not None:
|
|
657
|
+
inner_block["amytis_caption"] = caption_node.astext().strip()
|
|
658
|
+
container.parent.replace(container, inner_block)
|
|
659
|
+
|
|
660
|
+
# Pass 2: handle code-group containers. The directive marks them with the
|
|
661
|
+
# 'amytis-code-group-source' class. Per CLAUDE.md "strict build over silent
|
|
662
|
+
# runtime failure", malformed groups raise rather than getting dropped, and
|
|
663
|
+
# group ids are issued from a monotonic counter so two groups with identical
|
|
664
|
+
# label sets never share an id (which would couple their tab radios).
|
|
665
|
+
group_counter = 0
|
|
666
|
+
for container in list(document.findall(nodes.container)):
|
|
667
|
+
if "amytis-code-group-source" not in (container.get("classes") or []):
|
|
668
|
+
continue
|
|
669
|
+
|
|
670
|
+
inner_blocks = list(container.findall(nodes.literal_block))
|
|
671
|
+
if not inner_blocks:
|
|
672
|
+
raise RstRenderError(
|
|
673
|
+
"Empty or malformed '.. code-group::' directive: expected at least one nested .. code-block:: child."
|
|
674
|
+
)
|
|
675
|
+
|
|
676
|
+
markers: list[str] = []
|
|
677
|
+
labels: list[str] = []
|
|
678
|
+
for block in inner_blocks:
|
|
679
|
+
marker, label = _build_inner_block_marker(block)
|
|
680
|
+
markers.append(marker)
|
|
681
|
+
labels.append(label)
|
|
682
|
+
|
|
683
|
+
group_counter += 1
|
|
684
|
+
group_id = f"rst-{group_counter}"
|
|
685
|
+
labels_json = html.escape(json.dumps(labels, ensure_ascii=False), quote=True)
|
|
686
|
+
wrapper_html = (
|
|
687
|
+
f'<div data-amytis-code-group="" data-labels="{labels_json}" '
|
|
688
|
+
f'data-group-id="{group_id}">'
|
|
689
|
+
+ "".join(markers)
|
|
690
|
+
+ "</div>"
|
|
691
|
+
)
|
|
692
|
+
container.parent.replace(container, nodes.raw("", wrapper_html, format="html"))
|
|
693
|
+
|
|
694
|
+
# Pass 3: replace remaining (non-grouped) literal_blocks.
|
|
695
|
+
for block in list(document.findall(nodes.literal_block)):
|
|
696
|
+
marker, _ = _build_inner_block_marker(block)
|
|
697
|
+
block.parent.replace(block, nodes.raw("", marker, format="html"))
|
|
698
|
+
|
|
699
|
+
|
|
566
700
|
def extract_html_body_from_doctree(document: Any) -> str:
|
|
567
701
|
from docutils.core import publish_from_doctree
|
|
568
702
|
|
|
@@ -598,22 +732,92 @@ def build_output(document: Any, source_file: Path, image_base_slug: str, warning
|
|
|
598
732
|
raise RstRenderError("Missing document title.")
|
|
599
733
|
|
|
600
734
|
assets = extract_assets(document, source_file, image_base_slug)
|
|
735
|
+
# Read-only extractions first; the literal-block transformation mutates the tree.
|
|
736
|
+
text = extract_body_text(document)
|
|
737
|
+
headings = extract_headings(document)
|
|
738
|
+
metadata = extract_metadata(document)
|
|
739
|
+
|
|
740
|
+
transform_literal_blocks_to_markers(document)
|
|
601
741
|
html_body = extract_html_body_from_doctree(strip_preamble_nodes(document))
|
|
602
742
|
|
|
603
743
|
return {
|
|
604
744
|
"title": title_node.astext().strip(),
|
|
605
745
|
"html": rewrite_html_assets(html_body, assets),
|
|
606
|
-
"text":
|
|
607
|
-
"headings":
|
|
608
|
-
"metadata":
|
|
746
|
+
"text": text,
|
|
747
|
+
"headings": headings,
|
|
748
|
+
"metadata": metadata,
|
|
609
749
|
"assets": assets,
|
|
610
750
|
"warnings": list(dict.fromkeys(warnings)),
|
|
611
751
|
}
|
|
612
752
|
|
|
613
753
|
|
|
754
|
+
_amytis_directives_registered = False
|
|
755
|
+
|
|
756
|
+
|
|
757
|
+
def register_amytis_directives() -> None:
|
|
758
|
+
"""Register the .. code-group:: directive and a code-block subclass that
|
|
759
|
+
accepts a :label: option. Both are global to the docutils registry, so
|
|
760
|
+
registering once per process is enough.
|
|
761
|
+
|
|
762
|
+
The code-block override only ADDS the :label: option; standard behavior
|
|
763
|
+
(language argument, :linenos:, :emphasize-lines:, :caption:) goes through
|
|
764
|
+
docutils' built-in implementation unchanged. The label is stashed on the
|
|
765
|
+
resulting literal_block via a custom amytis_label attribute and consumed
|
|
766
|
+
by transform_literal_blocks_to_markers.
|
|
767
|
+
"""
|
|
768
|
+
global _amytis_directives_registered
|
|
769
|
+
if _amytis_directives_registered:
|
|
770
|
+
return
|
|
771
|
+
|
|
772
|
+
from docutils import nodes
|
|
773
|
+
from docutils.parsers.rst import Directive, directives
|
|
774
|
+
from docutils.parsers.rst.directives.body import CodeBlock as BaseCodeBlock
|
|
775
|
+
|
|
776
|
+
class LabeledCodeBlock(BaseCodeBlock):
|
|
777
|
+
option_spec = {
|
|
778
|
+
**BaseCodeBlock.option_spec,
|
|
779
|
+
"label": directives.unchanged,
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
def run(self):
|
|
783
|
+
result = super().run()
|
|
784
|
+
label = self.options.get("label")
|
|
785
|
+
if label:
|
|
786
|
+
for node in result:
|
|
787
|
+
for lb in node.findall(nodes.literal_block):
|
|
788
|
+
lb["amytis_label"] = label
|
|
789
|
+
return result
|
|
790
|
+
|
|
791
|
+
class CodeGroup(Directive):
|
|
792
|
+
"""Wrap nested code-blocks into a tabbed code-group.
|
|
793
|
+
|
|
794
|
+
Body content is parsed as rST and contributes literal_block children;
|
|
795
|
+
transform_literal_blocks_to_markers later consumes the whole subtree
|
|
796
|
+
and emits the <div data-amytis-code-group> wrapper marker.
|
|
797
|
+
"""
|
|
798
|
+
|
|
799
|
+
has_content = True
|
|
800
|
+
required_arguments = 0
|
|
801
|
+
optional_arguments = 0
|
|
802
|
+
option_spec = {}
|
|
803
|
+
|
|
804
|
+
def run(self):
|
|
805
|
+
wrapper = nodes.container()
|
|
806
|
+
wrapper["classes"].append("amytis-code-group-source")
|
|
807
|
+
self.state.nested_parse(self.content, self.content_offset, wrapper)
|
|
808
|
+
return [wrapper]
|
|
809
|
+
|
|
810
|
+
directives.register_directive("code-block", LabeledCodeBlock)
|
|
811
|
+
directives.register_directive("code", LabeledCodeBlock)
|
|
812
|
+
directives.register_directive("sourcecode", LabeledCodeBlock)
|
|
813
|
+
directives.register_directive("code-group", CodeGroup)
|
|
814
|
+
_amytis_directives_registered = True
|
|
815
|
+
|
|
816
|
+
|
|
614
817
|
def render_single_file(source_file: Path, image_base_slug: str, strict: bool) -> dict[str, Any]:
|
|
615
818
|
from docutils.core import publish_doctree
|
|
616
819
|
|
|
820
|
+
register_amytis_directives()
|
|
617
821
|
warnings: list[str] = []
|
|
618
822
|
source = normalize_legacy_doc_role_syntax(source_file.read_text(encoding="utf-8"))
|
|
619
823
|
with temporary_role_overrides(source_file, warnings):
|