@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.
- package/CHANGELOG.md +26 -0
- package/CLAUDE.md +90 -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 +217 -5
- package/docs/CODE-BLOCKS.md +238 -0
- package/docs/CONTRIBUTING.md +25 -0
- package/docs/guides/README.md +11 -0
- package/docs/guides/importing-vuepress-books.md +178 -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 +499 -0
- package/src/app/books/[slug]/{[chapter] → [...chapter]}/page.tsx +32 -10
- package/src/app/books/[slug]/page.tsx +67 -32
- package/src/app/globals.css +503 -123
- package/src/app/page.tsx +1 -1
- package/src/app/sitemap.ts +3 -3
- package/src/components/ArticleCopyCleaner.tsx +64 -0
- package/src/components/BookMobileNav.tsx +44 -50
- package/src/components/BookSidebar.tsx +0 -0
- package/src/components/CodeBlock.test.tsx +93 -8
- package/src/components/CodeBlock.tsx +39 -101
- package/src/components/CodeBlockToolbar.tsx +88 -0
- package/src/components/CodeGroup.tsx +81 -0
- package/src/components/CoverImage.tsx +1 -0
- package/src/components/ExternalLinkIcon.tsx +15 -0
- package/src/components/FeaturedStoriesSection.tsx +3 -3
- package/src/components/GithubAlert.tsx +97 -0
- package/src/components/MarkdownRenderer.test.tsx +14 -4
- package/src/components/MarkdownRenderer.tsx +144 -23
- package/src/components/Mermaid.tsx +32 -1
- package/src/components/PostList.tsx +1 -1
- package/src/components/PostNavigation.tsx +13 -2
- package/src/components/PostSidebar.tsx +13 -2
- package/src/components/RstRenderer.test.tsx +15 -15
- package/src/components/RstRenderer.tsx +37 -2
- package/src/components/Search.tsx +18 -4
- package/src/components/SeriesCatalog.tsx +1 -1
- package/src/components/ShareBar.tsx +5 -0
- package/src/components/TocPanel.tsx +10 -2
- package/src/i18n/translations.ts +2 -0
- package/src/layouts/BookLayout.tsx +35 -4
- package/src/layouts/PostLayout.tsx +5 -1
- package/src/lib/code-group-icons.test.ts +78 -0
- package/src/lib/code-group-icons.ts +148 -0
- package/src/lib/markdown.test.ts +56 -13
- package/src/lib/markdown.ts +203 -50
- 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/shiki-rst.ts +185 -0
- package/src/lib/shiki.test.ts +153 -0
- package/src/lib/shiki.ts +292 -0
- package/src/lib/urls.ts +57 -0
- package/src/test-utils/render.ts +23 -0
- package/tests/fixtures/sync-vuepress-book/docs/.vuepress/config.js +43 -0
- package/tests/fixtures/sync-vuepress-book/docs/intro/welcome.md +7 -0
- package/tests/fixtures/sync-vuepress-book/docs/maths/linear/assets/diagram.png +1 -0
- package/tests/fixtures/sync-vuepress-book/docs/maths/linear/matrices.md +7 -0
- package/tests/fixtures/sync-vuepress-book/docs/maths/linear/vectors.md +9 -0
- package/tests/helpers/env.ts +19 -0
- package/tests/integration/book-chapter-links.test.ts +107 -0
- package/tests/integration/books-nested-toc.test.ts +176 -0
- package/tests/integration/books.test.ts +3 -2
- package/tests/integration/code-block-features.test.ts +188 -0
- package/tests/integration/code-group.test.ts +183 -0
- package/tests/integration/code-notation.test.ts +97 -0
- package/tests/integration/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/sync-vuepress-book.test.ts +240 -0
- package/tests/integration/vuepress-containers.test.ts +107 -0
- package/tests/tooling/new-post.test.ts +1 -1
- package/tests/unit/static-params.test.ts +32 -19
package/src/lib/rst.test.ts
CHANGED
|
@@ -41,17 +41,227 @@ describe('rst utils', () => {
|
|
|
41
41
|
expect(markdown).toContain('');
|
|
42
42
|
});
|
|
43
43
|
|
|
44
|
-
test('
|
|
44
|
+
test('propagates :linenos:, :emphasize-lines:, and :caption: into the fence info string', () => {
|
|
45
|
+
const markdown = rstToMarkdown([
|
|
46
|
+
'.. code-block:: python',
|
|
47
|
+
' :linenos:',
|
|
48
|
+
' :emphasize-lines: 1,3-5',
|
|
49
|
+
' :caption: app.py',
|
|
50
|
+
'',
|
|
51
|
+
' def fib(n):',
|
|
52
|
+
' if n < 2:',
|
|
53
|
+
' return n',
|
|
54
|
+
' return fib(n - 1) + fib(n - 2)',
|
|
55
|
+
].join('\n'));
|
|
56
|
+
|
|
57
|
+
expect(markdown).toContain('```python title="app.py" linenos {1,3-5}');
|
|
58
|
+
expect(markdown).toContain('def fib(n):');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test(':language: option overrides the directive language', () => {
|
|
62
|
+
const markdown = rstToMarkdown([
|
|
63
|
+
'.. code-block::',
|
|
64
|
+
' :language: rust',
|
|
65
|
+
'',
|
|
66
|
+
' fn main() {}',
|
|
67
|
+
].join('\n'));
|
|
68
|
+
|
|
69
|
+
expect(markdown).toContain('```rust');
|
|
70
|
+
expect(markdown).toContain('fn main() {}');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('converts figure directives the same way as image directives', () => {
|
|
74
|
+
const bare = rstToMarkdown('.. figure:: _static/redis.svg');
|
|
75
|
+
expect(bare).toContain('');
|
|
76
|
+
|
|
77
|
+
const withAlt = rstToMarkdown([
|
|
78
|
+
'.. figure:: ./images/diagram.svg',
|
|
79
|
+
' :alt: A diagram',
|
|
80
|
+
].join('\n'));
|
|
81
|
+
expect(withAlt).toContain('');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('renders .. note:: as a markdown blockquote with a bold label', () => {
|
|
45
85
|
const markdown = rstToMarkdown([
|
|
46
86
|
'.. note::',
|
|
47
87
|
'',
|
|
48
88
|
' Keep this as prose.',
|
|
49
89
|
].join('\n'));
|
|
50
90
|
|
|
51
|
-
expect(markdown).toContain('
|
|
91
|
+
expect(markdown).toContain('> **Note**');
|
|
92
|
+
expect(markdown).toContain('> Keep this as prose.');
|
|
93
|
+
expect(markdown).not.toContain('.. note::');
|
|
52
94
|
expect(markdown).not.toContain('```');
|
|
53
95
|
});
|
|
54
96
|
|
|
97
|
+
test('renders all admonition kinds and preserves inline rST + blank lines', () => {
|
|
98
|
+
const warning = rstToMarkdown([
|
|
99
|
+
'.. WARNING::',
|
|
100
|
+
'',
|
|
101
|
+
' First line with ``code``.',
|
|
102
|
+
'',
|
|
103
|
+
' Second paragraph.',
|
|
104
|
+
].join('\n'));
|
|
105
|
+
|
|
106
|
+
expect(warning).toContain('> **Warning**');
|
|
107
|
+
expect(warning).toContain('> First line with `code`.');
|
|
108
|
+
expect(warning).toContain('> Second paragraph.');
|
|
109
|
+
expect(warning.split('\n').filter((line) => line === '>').length).toBeGreaterThanOrEqual(2);
|
|
110
|
+
|
|
111
|
+
for (const kind of ['tip', 'caution', 'attention', 'important', 'hint', 'danger', 'error']) {
|
|
112
|
+
const md = rstToMarkdown(`.. ${kind}::\n\n body`);
|
|
113
|
+
const label = kind.charAt(0).toUpperCase() + kind.slice(1);
|
|
114
|
+
expect(md).toContain(`> **${label}**`);
|
|
115
|
+
expect(md).toContain('> body');
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('passes unknown directives through as plain text', () => {
|
|
120
|
+
const markdown = rstToMarkdown([
|
|
121
|
+
'.. unknownthing::',
|
|
122
|
+
'',
|
|
123
|
+
' should not be swallowed',
|
|
124
|
+
].join('\n'));
|
|
125
|
+
|
|
126
|
+
expect(markdown).toContain('.. unknownthing::');
|
|
127
|
+
expect(markdown).not.toContain('> **Unknownthing**');
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('handles single-line admonitions and inline body with indented continuation', () => {
|
|
131
|
+
const singleLine = rstToMarkdown('.. note:: Quick reminder.');
|
|
132
|
+
expect(singleLine).toContain('> **Note**');
|
|
133
|
+
expect(singleLine).toContain('> Quick reminder.');
|
|
134
|
+
expect(singleLine).not.toContain('.. note::');
|
|
135
|
+
|
|
136
|
+
const withContinuation = rstToMarkdown([
|
|
137
|
+
'.. note:: 特别要说明的是,本文的内容不涉及任何真实的设计',
|
|
138
|
+
' 示例,和任何真正的商用秘密无关。',
|
|
139
|
+
].join('\n'));
|
|
140
|
+
expect(withContinuation).toContain('> **Note**');
|
|
141
|
+
expect(withContinuation).toContain('> 特别要说明的是,本文的内容不涉及任何真实的设计');
|
|
142
|
+
expect(withContinuation).toContain('> 示例,和任何真正的商用秘密无关。');
|
|
143
|
+
expect(withContinuation).not.toContain('.. note::');
|
|
144
|
+
|
|
145
|
+
const withParagraphBreak = rstToMarkdown([
|
|
146
|
+
'.. note:: First paragraph.',
|
|
147
|
+
'',
|
|
148
|
+
' Second paragraph.',
|
|
149
|
+
].join('\n'));
|
|
150
|
+
const lines = withParagraphBreak.split('\n');
|
|
151
|
+
const firstIdx = lines.findIndex((l) => l === '> First paragraph.');
|
|
152
|
+
const secondIdx = lines.findIndex((l) => l === '> Second paragraph.');
|
|
153
|
+
expect(firstIdx).toBeGreaterThan(-1);
|
|
154
|
+
expect(secondIdx).toBeGreaterThan(firstIdx);
|
|
155
|
+
expect(lines.slice(firstIdx + 1, secondIdx)).toContain('>');
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test('treats .. cnote:: as a custom admonition with :caption: support', () => {
|
|
159
|
+
const withCaption = rstToMarkdown([
|
|
160
|
+
'.. cnote::',
|
|
161
|
+
' :caption: 说明',
|
|
162
|
+
'',
|
|
163
|
+
' Body content here.',
|
|
164
|
+
].join('\n'));
|
|
165
|
+
|
|
166
|
+
expect(withCaption).toContain('> **说明**');
|
|
167
|
+
expect(withCaption).toContain('> Body content here.');
|
|
168
|
+
expect(withCaption).not.toContain(':caption:');
|
|
169
|
+
expect(withCaption).not.toContain('> **Cnote**');
|
|
170
|
+
|
|
171
|
+
const withoutCaption = rstToMarkdown([
|
|
172
|
+
'.. cnote::',
|
|
173
|
+
'',
|
|
174
|
+
' Default label fallback.',
|
|
175
|
+
].join('\n'));
|
|
176
|
+
|
|
177
|
+
expect(withoutCaption).toContain('> **Cnote**');
|
|
178
|
+
expect(withoutCaption).toContain('> Default label fallback.');
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test('renders line blocks (| prefixed lines) as a blockquote with hard breaks', () => {
|
|
182
|
+
const md = rstToMarkdown([
|
|
183
|
+
'Intro paragraph.',
|
|
184
|
+
'',
|
|
185
|
+
' | First poetic line.',
|
|
186
|
+
' | Second poetic line.',
|
|
187
|
+
' | Third with ``code``.',
|
|
188
|
+
'',
|
|
189
|
+
'Trailing paragraph.',
|
|
190
|
+
].join('\n'));
|
|
191
|
+
|
|
192
|
+
expect(md).toContain('> First poetic line. ');
|
|
193
|
+
expect(md).toContain('> Second poetic line. ');
|
|
194
|
+
expect(md).toContain('> Third with `code`.');
|
|
195
|
+
expect(md).not.toContain('| First');
|
|
196
|
+
const lines = md.split('\n');
|
|
197
|
+
const last = lines.findIndex((l) => l.startsWith('> Third'));
|
|
198
|
+
expect(last).toBeGreaterThan(-1);
|
|
199
|
+
expect(lines[last].endsWith(' ')).toBe(false);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test('renders :doc: cross-references and works alongside escaped whitespace', () => {
|
|
203
|
+
const md = rstToMarkdown([
|
|
204
|
+
'前面提到的\\ :doc:`AI编程中人的作用`\\ 这件事。',
|
|
205
|
+
'',
|
|
206
|
+
'See :doc:`Label <some/path>` for details.',
|
|
207
|
+
].join('\n'));
|
|
208
|
+
|
|
209
|
+
expect(md).toContain('前面提到的[AI编程中人的作用](AI编程中人的作用)这件事。');
|
|
210
|
+
expect(md).toContain('[Label](some/path)');
|
|
211
|
+
expect(md).not.toContain(':doc:');
|
|
212
|
+
expect(md).not.toContain('\\ ');
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test('renders :ref: and :numref: roles as anchor links', () => {
|
|
216
|
+
const md = rstToMarkdown([
|
|
217
|
+
'See :ref:`s_extension` for details.',
|
|
218
|
+
'',
|
|
219
|
+
'Look at :ref:`扩展机制 <s_extension>`.',
|
|
220
|
+
'',
|
|
221
|
+
'Per :numref:`图%s <图:架构图>` above.',
|
|
222
|
+
'',
|
|
223
|
+
'Bare :numref:`图:架构图` works too.',
|
|
224
|
+
].join('\n'));
|
|
225
|
+
|
|
226
|
+
expect(md).toContain('[s_extension](#s_extension)');
|
|
227
|
+
expect(md).toContain('[扩展机制](#s_extension)');
|
|
228
|
+
expect(md).toContain('[图](#图架构图)');
|
|
229
|
+
expect(md).toContain('[图:架构图](#图架构图)');
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test('suppresses .. toctree:: directive and its child list from rendered body', () => {
|
|
233
|
+
const markdown = rstToMarkdown([
|
|
234
|
+
'Intro paragraph.',
|
|
235
|
+
'',
|
|
236
|
+
'.. toctree::',
|
|
237
|
+
' :maxdepth: 2',
|
|
238
|
+
'',
|
|
239
|
+
' first-child',
|
|
240
|
+
' second-child',
|
|
241
|
+
'',
|
|
242
|
+
'Trailing paragraph.',
|
|
243
|
+
].join('\n'));
|
|
244
|
+
|
|
245
|
+
expect(markdown).toContain('Intro paragraph.');
|
|
246
|
+
expect(markdown).toContain('Trailing paragraph.');
|
|
247
|
+
expect(markdown).not.toContain('toctree');
|
|
248
|
+
expect(markdown).not.toContain('first-child');
|
|
249
|
+
expect(markdown).not.toContain('second-child');
|
|
250
|
+
expect(markdown).not.toContain(':maxdepth:');
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test('normalizes rST escaped whitespace inline', () => {
|
|
254
|
+
const doc = parseRstDocument([
|
|
255
|
+
'Title',
|
|
256
|
+
'=====',
|
|
257
|
+
'',
|
|
258
|
+
'前面提到的\\ ``code``\\ 这件事。',
|
|
259
|
+
].join('\n'));
|
|
260
|
+
|
|
261
|
+
expect(doc.markdownBody).toContain('前面提到的`code`这件事。');
|
|
262
|
+
expect(doc.markdownBody).not.toContain('\\ ');
|
|
263
|
+
});
|
|
264
|
+
|
|
55
265
|
test('ignores unknown metadata fields and rejects malformed supported values', () => {
|
|
56
266
|
const ignored = parseRstDocument([
|
|
57
267
|
'Title',
|
package/src/lib/rst.ts
CHANGED
|
@@ -36,7 +36,8 @@ export interface ParsedRstDocument {
|
|
|
36
36
|
metadata: RstMetadata;
|
|
37
37
|
headings: RstHeading[];
|
|
38
38
|
excerpt: string;
|
|
39
|
-
|
|
39
|
+
readingMinutes: number;
|
|
40
|
+
wordCount: number;
|
|
40
41
|
}
|
|
41
42
|
|
|
42
43
|
export class RstParseError extends Error {
|
|
@@ -287,9 +288,44 @@ function mergeMetadata(base: RstMetadata, override: RstMetadata): RstMetadata {
|
|
|
287
288
|
};
|
|
288
289
|
}
|
|
289
290
|
|
|
291
|
+
function slugifyAnchor(target: string): string {
|
|
292
|
+
return new GithubSlugger().slug(target.trim());
|
|
293
|
+
}
|
|
294
|
+
|
|
290
295
|
function convertInlineRst(text: string): string {
|
|
291
296
|
return text
|
|
297
|
+
.replace(/\\([ \t])/g, '')
|
|
292
298
|
.replace(/``([^`]+)``/g, '`$1`')
|
|
299
|
+
.replace(
|
|
300
|
+
/:ref:`([^<`]+?)\s*<([^>`]+)>`/g,
|
|
301
|
+
(_, title: string, target: string) => `[${title.trim()}](#${slugifyAnchor(target)})`,
|
|
302
|
+
)
|
|
303
|
+
.replace(
|
|
304
|
+
/:ref:`([^`]+)`/g,
|
|
305
|
+
(_, target: string) => `[${target.trim()}](#${slugifyAnchor(target)})`,
|
|
306
|
+
)
|
|
307
|
+
.replace(
|
|
308
|
+
/:numref:`([^<`]+?)\s*<([^>`]+)>`/g,
|
|
309
|
+
(_, title: string, target: string) => {
|
|
310
|
+
const label = title.replace(/%s/g, '').trim() || target.trim();
|
|
311
|
+
return `[${label}](#${slugifyAnchor(target)})`;
|
|
312
|
+
},
|
|
313
|
+
)
|
|
314
|
+
.replace(
|
|
315
|
+
/:numref:`([^`]+)`/g,
|
|
316
|
+
(_, target: string) => `[${target.trim()}](#${slugifyAnchor(target)})`,
|
|
317
|
+
)
|
|
318
|
+
.replace(
|
|
319
|
+
/:doc:`([^<`]+?)\s*<([^>`]+)>`/g,
|
|
320
|
+
(_, title: string, target: string) => `[${title.trim()}](${target.trim()})`,
|
|
321
|
+
)
|
|
322
|
+
.replace(
|
|
323
|
+
/:doc:`([^`]+)`/g,
|
|
324
|
+
(_, target: string) => {
|
|
325
|
+
const trimmed = target.trim();
|
|
326
|
+
return `[${trimmed}](${trimmed})`;
|
|
327
|
+
},
|
|
328
|
+
)
|
|
293
329
|
.replace(/`([^`]+?)\s*<([^>]+)>`__/g, '[$1]($2)')
|
|
294
330
|
.replace(/`([^`]+?)\s*<([^>]+)>`_/g, '[$1]($2)');
|
|
295
331
|
}
|
|
@@ -301,6 +337,73 @@ function detectHeadingLevel(adornment: string): number | null {
|
|
|
301
337
|
return null;
|
|
302
338
|
}
|
|
303
339
|
|
|
340
|
+
interface DirectiveCodeOptions {
|
|
341
|
+
language?: string;
|
|
342
|
+
caption?: string;
|
|
343
|
+
linenos?: boolean;
|
|
344
|
+
emphasizeLines?: string;
|
|
345
|
+
label?: string;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function readDirectiveOptions(
|
|
349
|
+
lines: string[],
|
|
350
|
+
startIndex: number,
|
|
351
|
+
): { options: DirectiveCodeOptions; nextLine: number } {
|
|
352
|
+
const options: DirectiveCodeOptions = {};
|
|
353
|
+
let i = startIndex;
|
|
354
|
+
while (i < lines.length) {
|
|
355
|
+
const match = lines[i].match(/^\s+:([A-Za-z-]+):\s*(.*)$/);
|
|
356
|
+
if (!match) break;
|
|
357
|
+
const key = match[1].toLowerCase();
|
|
358
|
+
const value = match[2].trim();
|
|
359
|
+
if (key === 'language') options.language = value;
|
|
360
|
+
else if (key === 'caption') options.caption = value;
|
|
361
|
+
else if (key === 'linenos') options.linenos = true;
|
|
362
|
+
else if (key === 'emphasize-lines') options.emphasizeLines = value;
|
|
363
|
+
else if (key === 'label') options.label = value;
|
|
364
|
+
i++;
|
|
365
|
+
}
|
|
366
|
+
// Skip the blank line separator that always follows the option block.
|
|
367
|
+
while (i < lines.length && !lines[i].trim()) i++;
|
|
368
|
+
return { options, nextLine: i };
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function buildFenceMetaFromOptions(options: DirectiveCodeOptions): string[] {
|
|
372
|
+
const meta: string[] = [];
|
|
373
|
+
// [label] must be the FIRST token after the language for the MDX-side
|
|
374
|
+
// parseFenceMeta + remark-code-group plugin to pick it up.
|
|
375
|
+
if (options.label) meta.push(`[${options.label}]`);
|
|
376
|
+
if (options.caption) meta.push(`title="${options.caption.replace(/"/g, '\\"')}"`);
|
|
377
|
+
if (options.linenos) meta.push('linenos');
|
|
378
|
+
if (options.emphasizeLines) meta.push(`{${options.emphasizeLines}}`);
|
|
379
|
+
return meta;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function convertNestedCodeBlocksToFences(body: string[]): string[] {
|
|
383
|
+
// Used by the .. code-group:: fallback path. Walks the indented body lines
|
|
384
|
+
// (already dedented to the directive's body indent) and emits Markdown
|
|
385
|
+
// fences for each nested .. code-block:: child. Anything else is dropped
|
|
386
|
+
// since :::code-group expects only code fences as children.
|
|
387
|
+
const out: string[] = [];
|
|
388
|
+
for (let i = 0; i < body.length; i++) {
|
|
389
|
+
const line = body[i];
|
|
390
|
+
const match = line.match(/^\.\.\s+(?:code-block|code|sourcecode)::\s*([A-Za-z0-9_+-]+)?\s*$/);
|
|
391
|
+
if (!match) continue;
|
|
392
|
+
const directiveLanguage = match[1] ?? '';
|
|
393
|
+
const { options, nextLine } = readDirectiveOptions(body, i + 1);
|
|
394
|
+
const { content, nextIndex } = readIndentedBlock(body, nextLine);
|
|
395
|
+
const language = options.language || directiveLanguage;
|
|
396
|
+
const fenceMeta = buildFenceMetaFromOptions(options);
|
|
397
|
+
const infoString = [language, ...fenceMeta].filter(Boolean).join(' ');
|
|
398
|
+
out.push(`\`\`\`${infoString}`.trimEnd());
|
|
399
|
+
out.push(...content);
|
|
400
|
+
out.push('```');
|
|
401
|
+
out.push('');
|
|
402
|
+
i = nextIndex - 1;
|
|
403
|
+
}
|
|
404
|
+
return out;
|
|
405
|
+
}
|
|
406
|
+
|
|
304
407
|
function readIndentedBlock(lines: string[], startIndex: number): { content: string[]; nextIndex: number } {
|
|
305
408
|
let i = startIndex;
|
|
306
409
|
while (i < lines.length && !lines[i].trim()) i++;
|
|
@@ -358,7 +461,79 @@ export function rstToMarkdown(body: string): string {
|
|
|
358
461
|
}
|
|
359
462
|
}
|
|
360
463
|
|
|
361
|
-
|
|
464
|
+
if (/^\.\.\s+toctree::\s*$/.test(line)) {
|
|
465
|
+
const { nextIndex } = readIndentedBlock(lines, i + 1);
|
|
466
|
+
i = nextIndex - 1;
|
|
467
|
+
continue;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
const lineBlockRegex = /^\s*\|(?:\s(.*))?$/;
|
|
471
|
+
if (lineBlockRegex.test(line)) {
|
|
472
|
+
const blockLines: string[] = [];
|
|
473
|
+
let j = i;
|
|
474
|
+
while (j < lines.length) {
|
|
475
|
+
const lineMatch = lines[j].match(lineBlockRegex);
|
|
476
|
+
if (!lineMatch) break;
|
|
477
|
+
blockLines.push((lineMatch[1] ?? '').trim());
|
|
478
|
+
j++;
|
|
479
|
+
}
|
|
480
|
+
out.push('');
|
|
481
|
+
blockLines.forEach((bl, idx) => {
|
|
482
|
+
const content = convertInlineRst(bl);
|
|
483
|
+
const isLast = idx === blockLines.length - 1;
|
|
484
|
+
out.push(isLast ? `> ${content}` : `> ${content} `);
|
|
485
|
+
});
|
|
486
|
+
out.push('');
|
|
487
|
+
i = j - 1;
|
|
488
|
+
continue;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
const admonitionMatch = line.match(
|
|
492
|
+
/^\.\.\s+(note|warning|tip|caution|attention|important|hint|danger|error|cnote)::(?:\s+(.*\S))?\s*$/i,
|
|
493
|
+
);
|
|
494
|
+
if (admonitionMatch) {
|
|
495
|
+
const kind = admonitionMatch[1].toLowerCase();
|
|
496
|
+
const inlineBody = admonitionMatch[2]?.trim() ?? '';
|
|
497
|
+
const { content, nextIndex } = readIndentedBlock(lines, i + 1);
|
|
498
|
+
|
|
499
|
+
let captionLabel: string | null = null;
|
|
500
|
+
let bodyStart = 0;
|
|
501
|
+
if (!inlineBody) {
|
|
502
|
+
while (bodyStart < content.length && content[bodyStart].trim() === '') bodyStart++;
|
|
503
|
+
while (bodyStart < content.length) {
|
|
504
|
+
const ln = content[bodyStart];
|
|
505
|
+
if (ln.trim() === '') {
|
|
506
|
+
bodyStart++;
|
|
507
|
+
break;
|
|
508
|
+
}
|
|
509
|
+
const optionMatch = ln.match(/^\s*:([A-Za-z-]+):\s*(.*)$/);
|
|
510
|
+
if (!optionMatch) break;
|
|
511
|
+
if (optionMatch[1].toLowerCase() === 'caption') {
|
|
512
|
+
captionLabel = optionMatch[2].trim();
|
|
513
|
+
}
|
|
514
|
+
bodyStart++;
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
const inlineHasParagraphBreak =
|
|
518
|
+
inlineBody && i + 1 < lines.length && lines[i + 1].trim() === '';
|
|
519
|
+
const bodyContent = inlineBody
|
|
520
|
+
? inlineHasParagraphBreak
|
|
521
|
+
? [inlineBody, '', ...content.slice(bodyStart)]
|
|
522
|
+
: [inlineBody, ...content.slice(bodyStart)]
|
|
523
|
+
: content.slice(bodyStart);
|
|
524
|
+
|
|
525
|
+
const label = captionLabel || (kind.charAt(0).toUpperCase() + kind.slice(1));
|
|
526
|
+
out.push(`> **${label}**`);
|
|
527
|
+
out.push('>');
|
|
528
|
+
for (const ln of bodyContent) {
|
|
529
|
+
out.push(ln.trim() === '' ? '>' : `> ${convertInlineRst(ln)}`);
|
|
530
|
+
}
|
|
531
|
+
out.push('');
|
|
532
|
+
i = nextIndex - 1;
|
|
533
|
+
continue;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
const imageMatch = line.match(/^\.\.\s+(?:image|figure)::\s+(.+?)\s*$/);
|
|
362
537
|
if (imageMatch) {
|
|
363
538
|
let alt = '';
|
|
364
539
|
let j = i + 1;
|
|
@@ -376,10 +551,30 @@ export function rstToMarkdown(body: string): string {
|
|
|
376
551
|
continue;
|
|
377
552
|
}
|
|
378
553
|
|
|
379
|
-
const
|
|
554
|
+
const codeGroupMatch = line.match(/^\.\.\s+code-group::\s*$/);
|
|
555
|
+
if (codeGroupMatch) {
|
|
556
|
+
// Collect the indented body — nested .. code-block:: blocks — and emit a
|
|
557
|
+
// :::code-group MDX directive so the result lands in the same MDX pipeline.
|
|
558
|
+
const { content: groupBody, nextIndex } = readIndentedBlock(lines, i + 1);
|
|
559
|
+
out.push(':::code-group');
|
|
560
|
+
out.push(...convertNestedCodeBlocksToFences(groupBody));
|
|
561
|
+
out.push(':::');
|
|
562
|
+
out.push('');
|
|
563
|
+
i = nextIndex - 1;
|
|
564
|
+
continue;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
const codeMatch = line.match(/^\.\.\s+(?:code-block|code|sourcecode)::\s*([A-Za-z0-9_+-]+)?\s*$/);
|
|
380
568
|
if (codeMatch) {
|
|
381
|
-
const
|
|
382
|
-
|
|
569
|
+
const directiveLanguage = codeMatch[1] ?? '';
|
|
570
|
+
const { options, nextLine } = readDirectiveOptions(lines, i + 1);
|
|
571
|
+
const { content, nextIndex } = readIndentedBlock(lines, nextLine);
|
|
572
|
+
|
|
573
|
+
const language = options.language || directiveLanguage;
|
|
574
|
+
const fenceMeta = buildFenceMetaFromOptions(options);
|
|
575
|
+
|
|
576
|
+
const infoString = [language, ...fenceMeta].filter(Boolean).join(' ');
|
|
577
|
+
out.push(`\`\`\`${infoString}`.trimEnd());
|
|
383
578
|
out.push(...content);
|
|
384
579
|
out.push('```');
|
|
385
580
|
out.push('');
|
|
@@ -415,10 +610,17 @@ export function rstToMarkdown(body: string): string {
|
|
|
415
610
|
return out.join('\n').trim();
|
|
416
611
|
}
|
|
417
612
|
|
|
418
|
-
function
|
|
613
|
+
function calculateReadingMinutes(content: string): number {
|
|
419
614
|
const wordsPerMinute = 200;
|
|
420
615
|
const hanCharsPerMinute = 300;
|
|
616
|
+
const { latinWords, hanChars } = countRstTokens(content);
|
|
617
|
+
const estimatedMinutes = (latinWords / wordsPerMinute) + (hanChars / hanCharsPerMinute);
|
|
618
|
+
return Math.max(1, Math.ceil(estimatedMinutes));
|
|
619
|
+
}
|
|
421
620
|
|
|
621
|
+
// Shared token counter — both reading-minutes and word-count need the same
|
|
622
|
+
// view of what counts as a word so they never disagree on the same input.
|
|
623
|
+
function countRstTokens(content: string): { latinWords: number; hanChars: number } {
|
|
422
624
|
const text = content
|
|
423
625
|
.replace(/<\/?[^>]+(>|$)/g, '')
|
|
424
626
|
.replace(/```[\s\S]*?```/g, '')
|
|
@@ -426,13 +628,14 @@ function calculateReadingTime(content: string): string {
|
|
|
426
628
|
.replace(/!\[[^\]]*\]\([^)]+\)/g, '')
|
|
427
629
|
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
|
|
428
630
|
.replace(/[#*_~>\-[\]()]/g, ' ');
|
|
631
|
+
const hanChars = (text.match(/[\u3400-\u4DBF\u4E00-\u9FFF\uF900-\uFAFF]/g) || []).length;
|
|
632
|
+
const latinWords = (text.match(/[A-Za-z0-9]+(?:['\u2019-][A-Za-z0-9]+)*/g) || []).length;
|
|
633
|
+
return { latinWords, hanChars };
|
|
634
|
+
}
|
|
429
635
|
|
|
430
|
-
|
|
431
|
-
const
|
|
432
|
-
|
|
433
|
-
const estimatedMinutes = (latinWordCount / wordsPerMinute) + (hanCharCount / hanCharsPerMinute);
|
|
434
|
-
const minutes = Math.max(1, Math.ceil(estimatedMinutes));
|
|
435
|
-
return `${minutes} min read`;
|
|
636
|
+
function calculateWordCount(content: string): number {
|
|
637
|
+
const { latinWords, hanChars } = countRstTokens(content);
|
|
638
|
+
return latinWords + hanChars;
|
|
436
639
|
}
|
|
437
640
|
|
|
438
641
|
function getHeadings(content: string): RstHeading[] {
|
|
@@ -465,6 +668,7 @@ export function parseRstDocument(source: string): ParsedRstDocument {
|
|
|
465
668
|
metadata,
|
|
466
669
|
headings: getHeadings(markdownBody),
|
|
467
670
|
excerpt: metadata.excerpt ?? '',
|
|
468
|
-
|
|
671
|
+
readingMinutes: calculateReadingMinutes(markdownBody),
|
|
672
|
+
wordCount: calculateWordCount(markdownBody),
|
|
469
673
|
};
|
|
470
674
|
}
|