@hutusi/amytis 1.14.0 → 1.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +1 -1
- package/.github/workflows/publish.yml +2 -2
- package/CHANGELOG.md +42 -0
- package/CLAUDE.md +90 -219
- package/README.md +33 -1
- package/README.zh.md +33 -1
- package/TODO.md +10 -0
- package/bun.lock +205 -539
- package/content/books/sample-book/index.mdx +3 -0
- package/content/posts/code-block-features-showcase.mdx +223 -0
- package/content/series/rst-legacy/deeper-notes/images/test.svg +4 -0
- package/content/series/rst-legacy/deeper-notes/index.rst +15 -0
- package/content/series/rst-legacy/getting-started.rst +24 -0
- package/content/series/rst-legacy/index.rst +9 -0
- package/content/series/rst-readme/README.rst +9 -0
- package/content/series/rst-readme/readme-index-post.rst +10 -0
- package/content/series/rst-toctree/first-post.rst +6 -0
- package/content/series/rst-toctree/index.rst +10 -0
- package/content/series/rst-toctree/second-post.rst +6 -0
- package/content/series/rst-toctree-precedence/first-post.rst +6 -0
- package/content/series/rst-toctree-precedence/index.rst +12 -0
- package/content/series/rst-toctree-precedence/second-post.rst +6 -0
- package/docs/ALERTS.md +112 -0
- package/docs/ARCHITECTURE.md +239 -8
- package/docs/CODE-BLOCKS.md +238 -0
- package/docs/CONTRIBUTING.md +36 -0
- package/docs/guides/README.md +11 -0
- package/docs/guides/importing-vuepress-books.md +178 -0
- package/eslint.config.mjs +20 -6
- package/next.config.ts +2 -2
- package/package.json +52 -24
- package/packages/create-amytis/package.json +1 -1
- package/packages/create-amytis/src/index.test.ts +43 -1
- package/packages/create-amytis/src/index.ts +64 -8
- package/public/next-image-export-optimizer-hashes.json +14 -73
- package/scripts/build-pagefind.ts +172 -0
- package/scripts/copy-assets.ts +246 -56
- package/scripts/generate-code-group-icons.ts +79 -0
- package/scripts/generate-knowledge-graph.ts +2 -1
- package/scripts/render-rst.py +923 -0
- package/scripts/run-with-rst-python.ts +42 -0
- package/scripts/sync-vuepress-book.ts +499 -0
- package/src/app/[slug]/[postSlug]/page.tsx +20 -10
- package/src/app/[slug]/page/[page]/page.tsx +15 -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 +639 -94
- package/src/app/page.tsx +1 -1
- package/src/app/series/[slug]/page/[page]/page.tsx +74 -6
- package/src/app/series/[slug]/page.tsx +11 -13
- package/src/app/series/page.tsx +3 -3
- package/src/app/sitemap.ts +3 -3
- package/src/components/ArticleCopyCleaner.tsx +64 -0
- package/src/components/AuthorCard.tsx +25 -16
- 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 +6 -2
- 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 +30 -4
- package/src/components/MarkdownRenderer.tsx +148 -24
- 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 +93 -0
- package/src/components/RstRenderer.tsx +157 -0
- 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 +10 -2
- package/src/layouts/SimpleLayout.tsx +10 -3
- package/src/lib/code-group-icons.test.ts +78 -0
- package/src/lib/code-group-icons.ts +148 -0
- package/src/lib/image-utils.test.ts +19 -0
- package/src/lib/image-utils.ts +11 -0
- package/src/lib/markdown.test.ts +195 -14
- package/src/lib/markdown.ts +928 -254
- package/src/lib/normalize-vuepress-math.ts +118 -0
- package/src/lib/rehype-fence-meta.ts +22 -0
- package/src/lib/rehype-image-metadata.ts +2 -2
- 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.test.ts +355 -0
- package/src/lib/rst-renderer.ts +629 -0
- package/src/lib/rst.test.ts +350 -0
- package/src/lib/rst.ts +674 -0
- package/src/lib/series-redirects.ts +42 -0
- 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/feed-utils.test.ts +13 -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 +12 -14
- package/tests/integration/series-draft.test.ts +12 -5
- package/tests/integration/series.test.ts +93 -0
- package/tests/integration/sync-vuepress-book.test.ts +240 -0
- package/tests/integration/vuepress-containers.test.ts +107 -0
- package/tests/tooling/build-pagefind.test.ts +66 -0
- package/tests/tooling/new-post.test.ts +1 -1
- package/tests/unit/static-params.test.ts +166 -13
package/src/lib/rst.ts
ADDED
|
@@ -0,0 +1,674 @@
|
|
|
1
|
+
import GithubSlugger from 'github-slugger';
|
|
2
|
+
|
|
3
|
+
export interface RstHeading {
|
|
4
|
+
id: string;
|
|
5
|
+
text: string;
|
|
6
|
+
level: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface RstMetadata {
|
|
10
|
+
date?: string;
|
|
11
|
+
subtitle?: string;
|
|
12
|
+
excerpt?: string;
|
|
13
|
+
category?: string;
|
|
14
|
+
tags?: string[];
|
|
15
|
+
authors?: string[];
|
|
16
|
+
author?: string;
|
|
17
|
+
layout?: string;
|
|
18
|
+
series?: string;
|
|
19
|
+
coverImage?: string;
|
|
20
|
+
sort?: 'date-desc' | 'date-asc' | 'manual';
|
|
21
|
+
posts?: string[];
|
|
22
|
+
featured?: boolean;
|
|
23
|
+
pinned?: boolean;
|
|
24
|
+
draft?: boolean;
|
|
25
|
+
latex?: boolean;
|
|
26
|
+
toc?: boolean;
|
|
27
|
+
commentable?: boolean;
|
|
28
|
+
redirectFrom?: string[];
|
|
29
|
+
type?: 'collection';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface ParsedRstDocument {
|
|
33
|
+
title: string;
|
|
34
|
+
body: string;
|
|
35
|
+
markdownBody: string;
|
|
36
|
+
metadata: RstMetadata;
|
|
37
|
+
headings: RstHeading[];
|
|
38
|
+
excerpt: string;
|
|
39
|
+
readingMinutes: number;
|
|
40
|
+
wordCount: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export class RstParseError extends Error {
|
|
44
|
+
constructor(message: string) {
|
|
45
|
+
super(message);
|
|
46
|
+
this.name = 'RstParseError';
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const SUPPORTED_FIELDS = new Set([
|
|
51
|
+
'date',
|
|
52
|
+
'subtitle',
|
|
53
|
+
'excerpt',
|
|
54
|
+
'category',
|
|
55
|
+
'tags',
|
|
56
|
+
'authors',
|
|
57
|
+
'author',
|
|
58
|
+
'layout',
|
|
59
|
+
'series',
|
|
60
|
+
'coverimage',
|
|
61
|
+
'sort',
|
|
62
|
+
'posts',
|
|
63
|
+
'featured',
|
|
64
|
+
'pinned',
|
|
65
|
+
'draft',
|
|
66
|
+
'latex',
|
|
67
|
+
'toc',
|
|
68
|
+
'commentable',
|
|
69
|
+
'redirectfrom',
|
|
70
|
+
'type',
|
|
71
|
+
]);
|
|
72
|
+
|
|
73
|
+
function normalizeLines(source: string): string[] {
|
|
74
|
+
return source.replace(/\r\n?/g, '\n').split('\n');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function isAdornmentLine(line: string): boolean {
|
|
78
|
+
const trimmed = line.trim();
|
|
79
|
+
return /^([=\-~^"`+#*])\1{2,}$/.test(trimmed);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function extractTitle(lines: string[]): { title: string; titleIndex: number; nextIndex: number } {
|
|
83
|
+
for (let i = 0; i + 1 < lines.length; i++) {
|
|
84
|
+
const titleLine = lines[i].trim();
|
|
85
|
+
const underline = lines[i + 1].trim();
|
|
86
|
+
|
|
87
|
+
if (!titleLine) continue;
|
|
88
|
+
if (/^\s/.test(lines[i])) continue;
|
|
89
|
+
if (!isAdornmentLine(underline)) continue;
|
|
90
|
+
|
|
91
|
+
return { title: titleLine, titleIndex: i, nextIndex: i + 2 };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
throw new RstParseError('Missing top-level rST title.');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function parseBoolean(field: string, value: string): boolean {
|
|
98
|
+
if (value === 'true') return true;
|
|
99
|
+
if (value === 'false') return false;
|
|
100
|
+
throw new RstParseError(`Invalid boolean for "${field}": ${value}`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function parseCsv(value: string): string[] {
|
|
104
|
+
if (!value.trim()) return [];
|
|
105
|
+
return value
|
|
106
|
+
.split(',')
|
|
107
|
+
.map(part => part.trim())
|
|
108
|
+
.filter(Boolean);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function parseDate(value: string): string {
|
|
112
|
+
const match = value.match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/);
|
|
113
|
+
if (!match) {
|
|
114
|
+
throw new RstParseError(`Invalid date: ${value}`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const [, year, month, day] = match;
|
|
118
|
+
const normalized = `${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}`;
|
|
119
|
+
|
|
120
|
+
const parsed = new Date(`${normalized}T00:00:00Z`);
|
|
121
|
+
if (Number.isNaN(parsed.getTime()) || parsed.toISOString().slice(0, 10) !== normalized) {
|
|
122
|
+
throw new RstParseError(`Invalid date: ${value}`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return normalized;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function parseSort(value: string): 'date-desc' | 'date-asc' | 'manual' {
|
|
129
|
+
if (value === 'date-desc' || value === 'date-asc' || value === 'manual') {
|
|
130
|
+
return value;
|
|
131
|
+
}
|
|
132
|
+
throw new RstParseError(`Invalid sort value: ${value}`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function parseType(value: string): 'collection' {
|
|
136
|
+
if (value === 'collection') {
|
|
137
|
+
return value;
|
|
138
|
+
}
|
|
139
|
+
throw new RstParseError(`Invalid type value: ${value}`);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function setMetadataField(metadata: RstMetadata, field: string, value: string): void {
|
|
143
|
+
const key = field.toLowerCase();
|
|
144
|
+
if (!SUPPORTED_FIELDS.has(key)) return;
|
|
145
|
+
|
|
146
|
+
switch (key) {
|
|
147
|
+
case 'date':
|
|
148
|
+
metadata.date = parseDate(value);
|
|
149
|
+
break;
|
|
150
|
+
case 'subtitle':
|
|
151
|
+
metadata.subtitle = value;
|
|
152
|
+
break;
|
|
153
|
+
case 'excerpt':
|
|
154
|
+
metadata.excerpt = value;
|
|
155
|
+
break;
|
|
156
|
+
case 'category':
|
|
157
|
+
metadata.category = value;
|
|
158
|
+
break;
|
|
159
|
+
case 'tags':
|
|
160
|
+
metadata.tags = parseCsv(value);
|
|
161
|
+
break;
|
|
162
|
+
case 'authors':
|
|
163
|
+
metadata.authors = parseCsv(value);
|
|
164
|
+
break;
|
|
165
|
+
case 'author':
|
|
166
|
+
metadata.author = value;
|
|
167
|
+
break;
|
|
168
|
+
case 'layout':
|
|
169
|
+
metadata.layout = value;
|
|
170
|
+
break;
|
|
171
|
+
case 'series':
|
|
172
|
+
metadata.series = value;
|
|
173
|
+
break;
|
|
174
|
+
case 'coverimage':
|
|
175
|
+
metadata.coverImage = value;
|
|
176
|
+
break;
|
|
177
|
+
case 'sort':
|
|
178
|
+
metadata.sort = parseSort(value);
|
|
179
|
+
break;
|
|
180
|
+
case 'posts':
|
|
181
|
+
metadata.posts = parseCsv(value);
|
|
182
|
+
break;
|
|
183
|
+
case 'featured':
|
|
184
|
+
metadata.featured = parseBoolean(field, value);
|
|
185
|
+
break;
|
|
186
|
+
case 'pinned':
|
|
187
|
+
metadata.pinned = parseBoolean(field, value);
|
|
188
|
+
break;
|
|
189
|
+
case 'draft':
|
|
190
|
+
metadata.draft = parseBoolean(field, value);
|
|
191
|
+
break;
|
|
192
|
+
case 'latex':
|
|
193
|
+
metadata.latex = parseBoolean(field, value);
|
|
194
|
+
break;
|
|
195
|
+
case 'toc':
|
|
196
|
+
metadata.toc = parseBoolean(field, value);
|
|
197
|
+
break;
|
|
198
|
+
case 'commentable':
|
|
199
|
+
metadata.commentable = parseBoolean(field, value);
|
|
200
|
+
break;
|
|
201
|
+
case 'redirectfrom':
|
|
202
|
+
metadata.redirectFrom = parseCsv(value);
|
|
203
|
+
break;
|
|
204
|
+
case 'type':
|
|
205
|
+
metadata.type = parseType(value);
|
|
206
|
+
break;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function extractMetadata(lines: string[], startIndex: number): { metadata: RstMetadata; nextIndex: number } {
|
|
211
|
+
const metadata: RstMetadata = {};
|
|
212
|
+
let i = startIndex;
|
|
213
|
+
while (i < lines.length && !lines[i].trim()) i++;
|
|
214
|
+
|
|
215
|
+
while (i < lines.length) {
|
|
216
|
+
const match = lines[i].match(/^:([A-Za-z][\w-]*):\s*(.*)$/);
|
|
217
|
+
if (!match) break;
|
|
218
|
+
|
|
219
|
+
const field = match[1];
|
|
220
|
+
const continuation: string[] = [match[2]];
|
|
221
|
+
i++;
|
|
222
|
+
|
|
223
|
+
while (i < lines.length) {
|
|
224
|
+
const next = lines[i];
|
|
225
|
+
if (!next.trim()) break;
|
|
226
|
+
if (/^:([A-Za-z][\w-]*):\s*(.*)$/.test(next)) break;
|
|
227
|
+
if (/^\s+/.test(next)) {
|
|
228
|
+
continuation.push(next.trim());
|
|
229
|
+
i++;
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
break;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
setMetadataField(metadata, field, continuation.join(' ').trim());
|
|
236
|
+
|
|
237
|
+
if (i < lines.length && !lines[i].trim()) {
|
|
238
|
+
i++;
|
|
239
|
+
if (i < lines.length && !/^:([A-Za-z][\w-]*):\s*(.*)$/.test(lines[i])) break;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
while (i < lines.length && !lines[i].trim()) i++;
|
|
244
|
+
return { metadata, nextIndex: i };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function extractPreambleMetadata(lines: string[]): RstMetadata {
|
|
248
|
+
const metadata: RstMetadata = {};
|
|
249
|
+
|
|
250
|
+
for (let i = 0; i < lines.length; i++) {
|
|
251
|
+
const match = lines[i].match(/^:([A-Za-z][\w-]*):\s*(.*)$/);
|
|
252
|
+
if (!match) continue;
|
|
253
|
+
|
|
254
|
+
const field = match[1];
|
|
255
|
+
const continuation: string[] = [match[2]];
|
|
256
|
+
i++;
|
|
257
|
+
|
|
258
|
+
while (i < lines.length) {
|
|
259
|
+
const next = lines[i];
|
|
260
|
+
if (!next.trim()) break;
|
|
261
|
+
if (/^:([A-Za-z][\w-]*):\s*(.*)$/.test(next)) {
|
|
262
|
+
i--;
|
|
263
|
+
break;
|
|
264
|
+
}
|
|
265
|
+
if (/^\s+/.test(next)) {
|
|
266
|
+
continuation.push(next.trim());
|
|
267
|
+
i++;
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
i--;
|
|
271
|
+
break;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
setMetadataField(metadata, field, continuation.join(' ').trim());
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return metadata;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function mergeMetadata(base: RstMetadata, override: RstMetadata): RstMetadata {
|
|
281
|
+
return {
|
|
282
|
+
...base,
|
|
283
|
+
...override,
|
|
284
|
+
tags: override.tags ?? base.tags,
|
|
285
|
+
authors: override.authors ?? base.authors,
|
|
286
|
+
posts: override.posts ?? base.posts,
|
|
287
|
+
redirectFrom: override.redirectFrom ?? base.redirectFrom,
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function slugifyAnchor(target: string): string {
|
|
292
|
+
return new GithubSlugger().slug(target.trim());
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function convertInlineRst(text: string): string {
|
|
296
|
+
return text
|
|
297
|
+
.replace(/\\([ \t])/g, '')
|
|
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
|
+
)
|
|
329
|
+
.replace(/`([^`]+?)\s*<([^>]+)>`__/g, '[$1]($2)')
|
|
330
|
+
.replace(/`([^`]+?)\s*<([^>]+)>`_/g, '[$1]($2)');
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function detectHeadingLevel(adornment: string): number | null {
|
|
334
|
+
const marker = adornment.trim()[0];
|
|
335
|
+
if (marker === '=') return 2;
|
|
336
|
+
if (marker === '-' || marker === '~' || marker === '^') return 3;
|
|
337
|
+
return null;
|
|
338
|
+
}
|
|
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
|
+
|
|
407
|
+
function readIndentedBlock(lines: string[], startIndex: number): { content: string[]; nextIndex: number } {
|
|
408
|
+
let i = startIndex;
|
|
409
|
+
while (i < lines.length && !lines[i].trim()) i++;
|
|
410
|
+
if (i >= lines.length || !/^\s+/.test(lines[i])) {
|
|
411
|
+
return { content: [], nextIndex: startIndex };
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const indent = lines[i].match(/^\s+/)?.[0].length ?? 0;
|
|
415
|
+
const content: string[] = [];
|
|
416
|
+
|
|
417
|
+
while (i < lines.length) {
|
|
418
|
+
const line = lines[i];
|
|
419
|
+
if (!line.trim()) {
|
|
420
|
+
content.push('');
|
|
421
|
+
i++;
|
|
422
|
+
continue;
|
|
423
|
+
}
|
|
424
|
+
const currentIndent = line.match(/^\s+/)?.[0].length ?? 0;
|
|
425
|
+
if (currentIndent < indent) break;
|
|
426
|
+
content.push(line.slice(indent));
|
|
427
|
+
i++;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
while (content.length > 0 && content[0] === '') content.shift();
|
|
431
|
+
while (content.length > 0 && content[content.length - 1] === '') content.pop();
|
|
432
|
+
|
|
433
|
+
return { content, nextIndex: i };
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
export function rstToMarkdown(body: string): string {
|
|
437
|
+
const lines = normalizeLines(body);
|
|
438
|
+
const out: string[] = [];
|
|
439
|
+
|
|
440
|
+
for (let i = 0; i < lines.length; i++) {
|
|
441
|
+
const line = lines[i];
|
|
442
|
+
const trimmed = line.trim();
|
|
443
|
+
|
|
444
|
+
if (!trimmed) {
|
|
445
|
+
out.push('');
|
|
446
|
+
continue;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
if (
|
|
450
|
+
i + 1 < lines.length &&
|
|
451
|
+
line.trim() &&
|
|
452
|
+
isAdornmentLine(lines[i + 1]) &&
|
|
453
|
+
!/^\s/.test(line)
|
|
454
|
+
) {
|
|
455
|
+
const level = detectHeadingLevel(lines[i + 1]);
|
|
456
|
+
if (level !== null) {
|
|
457
|
+
out.push(`${'#'.repeat(level)} ${convertInlineRst(trimmed)}`);
|
|
458
|
+
out.push('');
|
|
459
|
+
i++;
|
|
460
|
+
continue;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
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*$/);
|
|
537
|
+
if (imageMatch) {
|
|
538
|
+
let alt = '';
|
|
539
|
+
let j = i + 1;
|
|
540
|
+
while (j < lines.length && !lines[j].trim()) j++;
|
|
541
|
+
while (j < lines.length && /^\s+:[A-Za-z-]+:/.test(lines[j])) {
|
|
542
|
+
const optionMatch = lines[j].match(/^\s+:([A-Za-z-]+):\s*(.*)$/);
|
|
543
|
+
if (optionMatch?.[1].toLowerCase() === 'alt') {
|
|
544
|
+
alt = optionMatch[2].trim();
|
|
545
|
+
}
|
|
546
|
+
j++;
|
|
547
|
+
}
|
|
548
|
+
out.push(`})`);
|
|
549
|
+
out.push('');
|
|
550
|
+
i = j - 1;
|
|
551
|
+
continue;
|
|
552
|
+
}
|
|
553
|
+
|
|
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*$/);
|
|
568
|
+
if (codeMatch) {
|
|
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());
|
|
578
|
+
out.push(...content);
|
|
579
|
+
out.push('```');
|
|
580
|
+
out.push('');
|
|
581
|
+
i = nextIndex - 1;
|
|
582
|
+
continue;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
if (trimmed.endsWith('::') && !trimmed.startsWith('..')) {
|
|
586
|
+
const { content, nextIndex } = readIndentedBlock(lines, i + 1);
|
|
587
|
+
if (content.length > 0) {
|
|
588
|
+
const prefix = trimmed === '::' ? '' : convertInlineRst(trimmed.slice(0, -1));
|
|
589
|
+
if (prefix) {
|
|
590
|
+
out.push(prefix);
|
|
591
|
+
out.push('');
|
|
592
|
+
}
|
|
593
|
+
out.push('```');
|
|
594
|
+
out.push(...content);
|
|
595
|
+
out.push('```');
|
|
596
|
+
out.push('');
|
|
597
|
+
i = nextIndex - 1;
|
|
598
|
+
continue;
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
if (/^\s*[-*+]\s+/.test(line) || /^\s*\d+\.\s+/.test(line)) {
|
|
603
|
+
out.push(convertInlineRst(line));
|
|
604
|
+
continue;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
out.push(convertInlineRst(line));
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
return out.join('\n').trim();
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
function calculateReadingMinutes(content: string): number {
|
|
614
|
+
const wordsPerMinute = 200;
|
|
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
|
+
}
|
|
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 } {
|
|
624
|
+
const text = content
|
|
625
|
+
.replace(/<\/?[^>]+(>|$)/g, '')
|
|
626
|
+
.replace(/```[\s\S]*?```/g, '')
|
|
627
|
+
.replace(/`[^`]*`/g, '')
|
|
628
|
+
.replace(/!\[[^\]]*\]\([^)]+\)/g, '')
|
|
629
|
+
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
|
|
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
|
+
}
|
|
635
|
+
|
|
636
|
+
function calculateWordCount(content: string): number {
|
|
637
|
+
const { latinWords, hanChars } = countRstTokens(content);
|
|
638
|
+
return latinWords + hanChars;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
function getHeadings(content: string): RstHeading[] {
|
|
642
|
+
const regex = /^(#{2,3})\s+(.*)$/gm;
|
|
643
|
+
const headings: RstHeading[] = [];
|
|
644
|
+
const slugger = new GithubSlugger();
|
|
645
|
+
let match: RegExpExecArray | null;
|
|
646
|
+
|
|
647
|
+
while ((match = regex.exec(content)) !== null) {
|
|
648
|
+
const level = match[1].length;
|
|
649
|
+
const text = match[2].trim();
|
|
650
|
+
headings.push({ id: slugger.slug(text), text, level });
|
|
651
|
+
}
|
|
652
|
+
return headings;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
export function parseRstDocument(source: string): ParsedRstDocument {
|
|
656
|
+
const lines = normalizeLines(source);
|
|
657
|
+
const { title, titleIndex, nextIndex } = extractTitle(lines);
|
|
658
|
+
const preTitleMetadata = extractPreambleMetadata(lines.slice(0, titleIndex));
|
|
659
|
+
const { metadata: postTitleMetadata, nextIndex: contentIndex } = extractMetadata(lines, nextIndex);
|
|
660
|
+
const metadata = mergeMetadata(preTitleMetadata, postTitleMetadata);
|
|
661
|
+
const body = lines.slice(contentIndex).join('\n').trim();
|
|
662
|
+
const markdownBody = rstToMarkdown(body);
|
|
663
|
+
|
|
664
|
+
return {
|
|
665
|
+
title,
|
|
666
|
+
body,
|
|
667
|
+
markdownBody,
|
|
668
|
+
metadata,
|
|
669
|
+
headings: getHeadings(markdownBody),
|
|
670
|
+
excerpt: metadata.excerpt ?? '',
|
|
671
|
+
readingMinutes: calculateReadingMinutes(markdownBody),
|
|
672
|
+
wordCount: calculateWordCount(markdownBody),
|
|
673
|
+
};
|
|
674
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { getAllSeries, getSeriesData, PostData } from '@/lib/markdown';
|
|
2
|
+
|
|
3
|
+
export function safeDecodeParam(param: string): string {
|
|
4
|
+
try {
|
|
5
|
+
return decodeURIComponent(param);
|
|
6
|
+
} catch {
|
|
7
|
+
return param;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function normalizeRedirectPath(path: string): string | null {
|
|
12
|
+
const trimmed = path.trim();
|
|
13
|
+
if (!trimmed) return null;
|
|
14
|
+
|
|
15
|
+
try {
|
|
16
|
+
const decoded = decodeURIComponent(trimmed);
|
|
17
|
+
return decoded.startsWith('/') ? decoded : `/${decoded}`;
|
|
18
|
+
} catch {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function findSeriesByRedirectFrom(path: string): { slug: string; data: PostData } | null {
|
|
24
|
+
const normalizedPath = normalizeRedirectPath(path);
|
|
25
|
+
if (!normalizedPath) return null;
|
|
26
|
+
|
|
27
|
+
for (const seriesSlug of Object.keys(getAllSeries())) {
|
|
28
|
+
const data = getSeriesData(seriesSlug);
|
|
29
|
+
if (!data) continue;
|
|
30
|
+
|
|
31
|
+
const hasRedirect = (data.redirectFrom ?? []).some((redirectFrom) => {
|
|
32
|
+
const normalizedRedirect = normalizeRedirectPath(redirectFrom);
|
|
33
|
+
return normalizedRedirect === normalizedPath;
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
if (hasRedirect) {
|
|
37
|
+
return { slug: seriesSlug, data };
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return null;
|
|
42
|
+
}
|