@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/markdown.ts
CHANGED
|
@@ -5,6 +5,8 @@ import { siteConfig } from '../../site.config';
|
|
|
5
5
|
import GithubSlugger from 'github-slugger';
|
|
6
6
|
import { z } from 'zod';
|
|
7
7
|
import { getPostUrl } from './urls';
|
|
8
|
+
import { parseRstDocument, RstParseError } from './rst';
|
|
9
|
+
import { renderRstFile, renderRstFilesBatch, type RenderedRstDocument } from './rst-renderer';
|
|
8
10
|
|
|
9
11
|
const contentDirectory = path.join(process.cwd(), 'content', 'posts');
|
|
10
12
|
const pagesDirectory = path.join(process.cwd(), 'content');
|
|
@@ -13,6 +15,10 @@ const booksDirectory = path.join(process.cwd(), 'content', 'books');
|
|
|
13
15
|
const flowsDirectory = path.join(process.cwd(), 'content', 'flows');
|
|
14
16
|
const notesDirectory = path.join(process.cwd(), 'content', 'notes');
|
|
15
17
|
|
|
18
|
+
function readUtf8File(filePath: string): string {
|
|
19
|
+
return fs.readFileSync(/* turbopackIgnore: true */ filePath, 'utf8');
|
|
20
|
+
}
|
|
21
|
+
|
|
16
22
|
const ExternalLinkSchema = z.object({
|
|
17
23
|
name: z.string(),
|
|
18
24
|
url: z.string().url(),
|
|
@@ -117,19 +123,151 @@ export interface PostData {
|
|
|
117
123
|
commentable?: boolean;
|
|
118
124
|
externalLinks?: ExternalLink[];
|
|
119
125
|
redirectFrom?: string[];
|
|
120
|
-
|
|
126
|
+
readingMinutes: number;
|
|
127
|
+
wordCount: number;
|
|
121
128
|
content: string;
|
|
129
|
+
renderedHtml?: string;
|
|
130
|
+
plainText?: string;
|
|
122
131
|
headings: Heading[];
|
|
123
132
|
contentLocales?: Record<string, { content: string; title?: string; excerpt?: string; headings?: Heading[] }>;
|
|
124
133
|
/** Public-relative base path used for resolving co-located images (e.g. "posts/my-post" or "posts" for root flat files). */
|
|
125
134
|
imageBaseSlug: string;
|
|
135
|
+
sourceFormat?: 'markdown' | 'rst';
|
|
126
136
|
}
|
|
127
137
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
138
|
+
type SeriesFormat = 'markdown' | 'rst';
|
|
139
|
+
|
|
140
|
+
interface SeriesIndexInfo {
|
|
141
|
+
format: SeriesFormat;
|
|
142
|
+
fullPath: string;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
interface SeriesContentEntry {
|
|
146
|
+
fullPath: string;
|
|
147
|
+
slug: string;
|
|
148
|
+
dateFromFileName?: string;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
interface PendingRstPostEntry {
|
|
152
|
+
fullPath: string;
|
|
153
|
+
slug: string;
|
|
154
|
+
dateFromFileName?: string;
|
|
155
|
+
seriesSlug?: string;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function getCacheEnvKey(): string {
|
|
159
|
+
return process.env.NODE_ENV === 'production' ? 'production' : 'development';
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const postsCache = new Map<string, PostData[]>();
|
|
163
|
+
const pagesCache = new Map<string, PostData[]>();
|
|
164
|
+
const tagsCache = new Map<string, Record<string, number>>();
|
|
165
|
+
const authorsCache = new Map<string, Record<string, number>>();
|
|
166
|
+
const featuredPostsCache = new Map<string, PostData[]>();
|
|
167
|
+
const adjacentPostsCache = new Map<string, Map<string, { prev: PostData | null; next: PostData | null }>>();
|
|
168
|
+
const relatedPostsCache = new Map<string, Map<string, PostData[]>>();
|
|
169
|
+
const seriesDataCache = new Map<string, Map<string, PostData | null>>();
|
|
170
|
+
const seriesPostsCache = new Map<string, Map<string, PostData[]>>();
|
|
171
|
+
const allSeriesCache = new Map<string, Record<string, PostData[]>>();
|
|
172
|
+
const featuredSeriesCache = new Map<string, Record<string, PostData[]>>();
|
|
173
|
+
const seriesLatestDateCache = new Map<string, Map<string, string>>();
|
|
174
|
+
const collectionPostsCache = new Map<string, Map<string, PostData[]>>();
|
|
175
|
+
const collectionsForPostCache = new Map<string, Map<string, CollectionContext[]>>();
|
|
176
|
+
const seriesAuthorsCache = new Map<string, Map<string, string[] | null>>();
|
|
177
|
+
const seriesTitleCache = new Map<string, Map<string, string | undefined>>();
|
|
178
|
+
let pythonRstRendererAvailable: boolean | null = null;
|
|
179
|
+
|
|
180
|
+
const PYTHON_RUNTIME_UNAVAILABLE_PATTERN = /docutils|No module named|python(?:3)? .*not found|interpreter not found|ENOENT.*python/i;
|
|
181
|
+
|
|
182
|
+
function isPythonRuntimeUnavailable(error: unknown): boolean {
|
|
183
|
+
if (!(error instanceof Error)) return false;
|
|
184
|
+
if (error.message.includes('__RST_FALLBACK__')) return true;
|
|
185
|
+
if (error.message.includes('rST file not found')) return false;
|
|
186
|
+
return PYTHON_RUNTIME_UNAVAILABLE_PATTERN.test(error.message);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function getRstImageBaseSlug(fullPath: string, slug: string): string {
|
|
190
|
+
const isRootFlatPost = path.basename(fullPath) !== 'index.rst' &&
|
|
191
|
+
path.dirname(fullPath) === contentDirectory;
|
|
192
|
+
return isRootFlatPost ? 'posts' : `posts/${slug}`;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function isSeriesIndexRst(fullPath: string, slug: string, seriesName?: string): boolean {
|
|
196
|
+
return Boolean(
|
|
197
|
+
seriesName &&
|
|
198
|
+
slug === seriesName &&
|
|
199
|
+
(path.basename(fullPath) === 'index.rst' || path.basename(fullPath) === 'README.rst')
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function slugFromRstToctreeTarget(target: string): string | null {
|
|
204
|
+
const trimmed = target.trim();
|
|
205
|
+
if (!trimmed || trimmed.startsWith(':')) return null;
|
|
206
|
+
if (/^[a-z]+:\/\//i.test(trimmed) || trimmed.startsWith('/')) return null;
|
|
207
|
+
|
|
208
|
+
const withoutAnchor = trimmed.split('#')[0]?.split('?')[0]?.trim();
|
|
209
|
+
if (!withoutAnchor) return null;
|
|
210
|
+
|
|
211
|
+
const normalized = withoutAnchor.replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/+$/, '');
|
|
212
|
+
if (!normalized || normalized.startsWith('../')) return null;
|
|
213
|
+
|
|
214
|
+
const withoutExt = normalized.replace(/\.rst$/i, '');
|
|
215
|
+
const parts = withoutExt.split('/').filter(Boolean);
|
|
216
|
+
if (parts.length === 0) return null;
|
|
217
|
+
|
|
218
|
+
const last = parts[parts.length - 1];
|
|
219
|
+
if (last === 'index' || last === 'README') {
|
|
220
|
+
return parts.length > 1 ? parts[parts.length - 2] : null;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return last;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function extractRstToctreePosts(source: string): string[] {
|
|
227
|
+
const lines = source.replace(/\r\n?/g, '\n').split('\n');
|
|
228
|
+
const posts: string[] = [];
|
|
229
|
+
const seen = new Set<string>();
|
|
230
|
+
|
|
231
|
+
for (let i = 0; i < lines.length; i++) {
|
|
232
|
+
if (!/^\s*\.\.\s+toctree::\s*$/.test(lines[i])) continue;
|
|
233
|
+
|
|
234
|
+
i++;
|
|
235
|
+
while (i < lines.length) {
|
|
236
|
+
const line = lines[i];
|
|
237
|
+
if (!line.trim()) {
|
|
238
|
+
i++;
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
if (!/^\s+/.test(line)) {
|
|
242
|
+
i--;
|
|
243
|
+
break;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const trimmed = line.trim();
|
|
247
|
+
if (!trimmed.startsWith(':')) {
|
|
248
|
+
const slug = slugFromRstToctreeTarget(trimmed);
|
|
249
|
+
if (slug && !seen.has(slug)) {
|
|
250
|
+
seen.add(slug);
|
|
251
|
+
posts.push(slug);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
i++;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
131
257
|
|
|
132
|
-
|
|
258
|
+
return posts;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function shouldUsePythonRstRenderer(): boolean {
|
|
262
|
+
if (process.env.AMYTIS_ENABLE_PYTHON_RST === '1') return true;
|
|
263
|
+
if (process.env.AMYTIS_ENABLE_PYTHON_RST === '0') return false;
|
|
264
|
+
return process.env.NODE_ENV !== 'test';
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Shared text-stripping + tokenization used by both `calculateReadingMinutes`
|
|
268
|
+
// and `calculateWordCount`. Both metrics need the same view of "what counts
|
|
269
|
+
// as a word," so funnel them through a single source of truth.
|
|
270
|
+
function countContentTokens(content: string): { latinWords: number; hanChars: number } {
|
|
133
271
|
const text = content
|
|
134
272
|
.replace(/<\/?[^>]+(>|$)/g, "")
|
|
135
273
|
.replace(/```[\s\S]*?```/g, "")
|
|
@@ -137,15 +275,47 @@ export function calculateReadingTime(content: string): string {
|
|
|
137
275
|
.replace(/!\[[^\]]*\]\([^)]+\)/g, "")
|
|
138
276
|
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
|
|
139
277
|
.replace(/[#*_~>\-[\]()]/g, " ");
|
|
278
|
+
return countTokenizedText(text);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function countTokenizedText(text: string): { latinWords: number; hanChars: number } {
|
|
282
|
+
const hanChars = (text.match(HAN_CHAR_RE) || []).length;
|
|
283
|
+
const latinWords = (text.match(LATIN_WORD_RE) || []).length;
|
|
284
|
+
return { latinWords, hanChars };
|
|
285
|
+
}
|
|
140
286
|
|
|
141
|
-
|
|
142
|
-
|
|
287
|
+
// Han character ranges: CJK Unified Ideographs Extension A, CJK Unified
|
|
288
|
+
// Ideographs, CJK Compatibility Ideographs.
|
|
289
|
+
const HAN_CHAR_RE = /[㐀-䶿一-鿿豈-]/g;
|
|
290
|
+
// Latin word: alphanumeric runs allowing apostrophes/hyphens between runs.
|
|
291
|
+
const LATIN_WORD_RE = /[A-Za-z0-9]+(?:['’-][A-Za-z0-9]+)*/g;
|
|
143
292
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
293
|
+
/**
|
|
294
|
+
* Estimated minutes-to-read, ceiled to a whole minute and floored at 1.
|
|
295
|
+
* Returns a raw number so layouts can localize via `t('reading_time')`
|
|
296
|
+
* — store-as-number rather than pre-baked "N min read" string lets
|
|
297
|
+
* the locale switch take effect at render time.
|
|
298
|
+
*/
|
|
299
|
+
export function calculateReadingMinutes(content: string): number {
|
|
300
|
+
const wordsPerMinute = 200;
|
|
301
|
+
const hanCharsPerMinute = 300;
|
|
302
|
+
const { latinWords, hanChars } = countContentTokens(content);
|
|
303
|
+
const estimatedMinutes = (latinWords / wordsPerMinute) + (hanChars / hanCharsPerMinute);
|
|
304
|
+
return Math.max(1, Math.ceil(estimatedMinutes));
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Aggregate word count: Latin word matches plus Han characters.
|
|
309
|
+
* Han is counted per-character (the convention in Chinese typography
|
|
310
|
+
* — "字数" literally means "character count") while Latin counts per
|
|
311
|
+
* whitespace-bounded token. Returns 0 for empty input.
|
|
312
|
+
*/
|
|
313
|
+
export function calculateWordCount(content: string): number {
|
|
314
|
+
const { latinWords, hanChars } = countContentTokens(content);
|
|
315
|
+
return latinWords + hanChars;
|
|
147
316
|
}
|
|
148
317
|
|
|
318
|
+
|
|
149
319
|
export function generateExcerpt(content: string): string {
|
|
150
320
|
let plain = content.replace(/^#+\s+/gm, '');
|
|
151
321
|
plain = plain.replace(/```[\s\S]*?```/g, '');
|
|
@@ -183,22 +353,47 @@ export function getHeadings(content: string): Heading[] {
|
|
|
183
353
|
* Returns null if no authors are configured (as opposed to the default fallback).
|
|
184
354
|
*/
|
|
185
355
|
export function getSeriesAuthors(seriesSlug: string): string[] | null {
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
356
|
+
const cacheKey = getCacheEnvKey();
|
|
357
|
+
let bySlug = seriesAuthorsCache.get(cacheKey);
|
|
358
|
+
if (!bySlug) {
|
|
359
|
+
bySlug = new Map();
|
|
360
|
+
seriesAuthorsCache.set(cacheKey, bySlug);
|
|
361
|
+
}
|
|
362
|
+
if (bySlug.has(seriesSlug)) return bySlug.get(seriesSlug) ?? null;
|
|
189
363
|
|
|
190
|
-
|
|
191
|
-
if (
|
|
192
|
-
|
|
193
|
-
|
|
364
|
+
const indexInfo = resolveSeriesIndexInfo(seriesSlug);
|
|
365
|
+
if (!indexInfo) {
|
|
366
|
+
bySlug.set(seriesSlug, null);
|
|
367
|
+
return null;
|
|
368
|
+
}
|
|
194
369
|
|
|
195
|
-
|
|
370
|
+
if (indexInfo.format === 'rst') {
|
|
371
|
+
const parsed = parseRstDocument(readUtf8File(indexInfo.fullPath));
|
|
372
|
+
if (parsed.metadata.authors && parsed.metadata.authors.length > 0) {
|
|
373
|
+
bySlug.set(seriesSlug, parsed.metadata.authors);
|
|
374
|
+
return parsed.metadata.authors;
|
|
375
|
+
}
|
|
376
|
+
if (parsed.metadata.author && typeof parsed.metadata.author === 'string') {
|
|
377
|
+
const authors = [parsed.metadata.author];
|
|
378
|
+
bySlug.set(seriesSlug, authors);
|
|
379
|
+
return authors;
|
|
380
|
+
}
|
|
381
|
+
bySlug.set(seriesSlug, null);
|
|
382
|
+
return null;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const { data } = matter(readUtf8File(indexInfo.fullPath));
|
|
196
386
|
if (data.authors && Array.isArray(data.authors) && data.authors.length > 0) {
|
|
197
|
-
|
|
387
|
+
const authors = data.authors as string[];
|
|
388
|
+
bySlug.set(seriesSlug, authors);
|
|
389
|
+
return authors;
|
|
198
390
|
}
|
|
199
391
|
if (data.author && typeof data.author === 'string') {
|
|
200
|
-
|
|
392
|
+
const authors = [data.author as string];
|
|
393
|
+
bySlug.set(seriesSlug, authors);
|
|
394
|
+
return authors;
|
|
201
395
|
}
|
|
396
|
+
bySlug.set(seriesSlug, null);
|
|
202
397
|
return null;
|
|
203
398
|
}
|
|
204
399
|
|
|
@@ -221,17 +416,186 @@ export function resolveSeriesAuthors(slug: string, posts: PostData[]): string[]
|
|
|
221
416
|
.map(([name]) => name);
|
|
222
417
|
}
|
|
223
418
|
|
|
419
|
+
function parseSlugAndDate(rawName: string): { slug: string; dateFromFileName?: string } {
|
|
420
|
+
const dateRegex = /^(\d{4}-\d{2}-\d{2})-(.*)$/;
|
|
421
|
+
const match = rawName.match(dateRegex);
|
|
422
|
+
|
|
423
|
+
if (match) {
|
|
424
|
+
return {
|
|
425
|
+
dateFromFileName: match[1],
|
|
426
|
+
slug: siteConfig.posts?.includeDateInUrl ? rawName : match[2],
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
return { slug: rawName };
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function isMarkdownFilename(name: string): boolean {
|
|
434
|
+
return name.endsWith('.md') || name.endsWith('.mdx');
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function isRstFilename(name: string): boolean {
|
|
438
|
+
return name.endsWith('.rst');
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function assertSafeSeriesSlug(seriesSlug: string): void {
|
|
442
|
+
if (!seriesSlug || path.isAbsolute(seriesSlug)) {
|
|
443
|
+
throw new Error(`[amytis] Invalid series slug "${seriesSlug}".`);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
const segments = seriesSlug.split(/[\\/]/);
|
|
447
|
+
if (segments.length !== 1 || segments[0] === '.' || segments[0] === '..') {
|
|
448
|
+
throw new Error(`[amytis] Invalid series slug "${seriesSlug}".`);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function resolveUniqueSeriesIndex(seriesSlug: string, format: SeriesFormat): string | null {
|
|
453
|
+
assertSafeSeriesSlug(seriesSlug);
|
|
454
|
+
const seriesPath = path.join(seriesDirectory, seriesSlug);
|
|
455
|
+
const candidates = format === 'rst'
|
|
456
|
+
? ['index.rst', 'README.rst']
|
|
457
|
+
: ['index.mdx', 'index.md', 'README.mdx', 'README.md'];
|
|
458
|
+
|
|
459
|
+
const matches = candidates
|
|
460
|
+
.map(name => path.join(seriesPath, name))
|
|
461
|
+
.filter(fullPath => fs.existsSync(fullPath));
|
|
462
|
+
|
|
463
|
+
if (matches.length > 1) {
|
|
464
|
+
throw new Error(
|
|
465
|
+
`[amytis] Series "${seriesSlug}" has multiple ${format} index files: ${matches.map(match => path.basename(match)).join(', ')}.`
|
|
466
|
+
);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
return matches[0] ?? null;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function resolveSeriesIndexInfo(slug: string): SeriesIndexInfo | null {
|
|
473
|
+
assertSafeSeriesSlug(slug);
|
|
474
|
+
if (!fs.existsSync(seriesDirectory)) return null;
|
|
475
|
+
const seriesPath = path.join(seriesDirectory, slug);
|
|
476
|
+
if (!fs.existsSync(seriesPath) || !fs.statSync(seriesPath).isDirectory()) return null;
|
|
477
|
+
|
|
478
|
+
const rstIndex = resolveUniqueSeriesIndex(slug, 'rst');
|
|
479
|
+
const markdownIndex = resolveUniqueSeriesIndex(slug, 'markdown');
|
|
480
|
+
|
|
481
|
+
if (rstIndex && markdownIndex) {
|
|
482
|
+
throw new Error(
|
|
483
|
+
`[amytis] Series "${slug}" cannot contain both rST and Markdown index files (${path.basename(rstIndex)} and ${path.basename(markdownIndex)}).`
|
|
484
|
+
);
|
|
485
|
+
}
|
|
486
|
+
if (rstIndex) return { format: 'rst', fullPath: rstIndex };
|
|
487
|
+
if (markdownIndex) return { format: 'markdown', fullPath: markdownIndex };
|
|
488
|
+
return null;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function getSeriesContentEntries(seriesSlug: string): SeriesContentEntry[] {
|
|
492
|
+
const indexInfo = resolveSeriesIndexInfo(seriesSlug);
|
|
493
|
+
if (!indexInfo) return [];
|
|
494
|
+
|
|
495
|
+
const seriesPath = path.join(seriesDirectory, seriesSlug);
|
|
496
|
+
const seriesItems = fs.readdirSync(seriesPath, { withFileTypes: true });
|
|
497
|
+
const entries: SeriesContentEntry[] = [];
|
|
498
|
+
const seenSlugs = new Map<string, string>();
|
|
499
|
+
const seriesIndexBasenames = new Set(['index.rst', 'README.rst', 'index.md', 'index.mdx', 'README.md', 'README.mdx']);
|
|
500
|
+
|
|
501
|
+
for (const item of seriesItems) {
|
|
502
|
+
if (seriesIndexBasenames.has(item.name)) continue;
|
|
503
|
+
|
|
504
|
+
if (item.isFile()) {
|
|
505
|
+
const isMarkdown = isMarkdownFilename(item.name);
|
|
506
|
+
const isRst = isRstFilename(item.name);
|
|
507
|
+
if (!isMarkdown && !isRst) continue;
|
|
508
|
+
|
|
509
|
+
const itemFormat: SeriesFormat = isRst ? 'rst' : 'markdown';
|
|
510
|
+
if (itemFormat !== indexInfo.format) {
|
|
511
|
+
throw new Error(`[amytis] Series "${seriesSlug}" mixes ${indexInfo.format} and ${itemFormat} files. Offending file: ${item.name}`);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
const rawName = item.name.replace(/\.(mdx?|rst)$/, '');
|
|
515
|
+
const { slug, dateFromFileName } = parseSlugAndDate(rawName);
|
|
516
|
+
const prior = seenSlugs.get(slug);
|
|
517
|
+
if (prior) {
|
|
518
|
+
throw new Error(`[amytis] Series "${seriesSlug}" contains duplicate post slug "${slug}" from "${prior}" and "${item.name}".`);
|
|
519
|
+
}
|
|
520
|
+
seenSlugs.set(slug, item.name);
|
|
521
|
+
entries.push({ fullPath: path.join(seriesPath, item.name), slug, dateFromFileName });
|
|
522
|
+
continue;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
if (item.isDirectory()) {
|
|
526
|
+
const folderPath = path.join(seriesPath, item.name);
|
|
527
|
+
const folderIndexRst = path.join(folderPath, 'index.rst');
|
|
528
|
+
const folderIndexMdx = path.join(folderPath, 'index.mdx');
|
|
529
|
+
const folderIndexMd = path.join(folderPath, 'index.md');
|
|
530
|
+
const hasRst = fs.existsSync(folderIndexRst);
|
|
531
|
+
const hasMdx = fs.existsSync(folderIndexMdx);
|
|
532
|
+
const hasMd = fs.existsSync(folderIndexMd);
|
|
533
|
+
const markdownCount = Number(hasMdx) + Number(hasMd);
|
|
534
|
+
const totalIndexCount = Number(hasRst) + markdownCount;
|
|
535
|
+
|
|
536
|
+
if (totalIndexCount === 0) continue;
|
|
537
|
+
if (hasRst && markdownCount > 0) {
|
|
538
|
+
throw new Error(`[amytis] Series "${seriesSlug}" post folder "${item.name}" cannot contain both index.rst and Markdown index files.`);
|
|
539
|
+
}
|
|
540
|
+
if (markdownCount > 1) {
|
|
541
|
+
throw new Error(`[amytis] Series "${seriesSlug}" post folder "${item.name}" cannot contain both index.md and index.mdx.`);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
const itemFormat: SeriesFormat = hasRst ? 'rst' : 'markdown';
|
|
545
|
+
if (itemFormat !== indexInfo.format) {
|
|
546
|
+
throw new Error(`[amytis] Series "${seriesSlug}" mixes ${indexInfo.format} and ${itemFormat} files. Offending folder: ${item.name}`);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
const { slug, dateFromFileName } = parseSlugAndDate(item.name);
|
|
550
|
+
const prior = seenSlugs.get(slug);
|
|
551
|
+
if (prior) {
|
|
552
|
+
throw new Error(`[amytis] Series "${seriesSlug}" contains duplicate post slug "${slug}" from "${prior}" and "${item.name}".`);
|
|
553
|
+
}
|
|
554
|
+
seenSlugs.set(slug, item.name);
|
|
555
|
+
entries.push({
|
|
556
|
+
fullPath: hasRst ? folderIndexRst : (hasMdx ? folderIndexMdx : folderIndexMd),
|
|
557
|
+
slug,
|
|
558
|
+
dateFromFileName,
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
return entries;
|
|
564
|
+
}
|
|
565
|
+
|
|
224
566
|
function getSeriesTitle(slug: string): string | undefined {
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
567
|
+
const cacheKey = getCacheEnvKey();
|
|
568
|
+
let bySlug = seriesTitleCache.get(cacheKey);
|
|
569
|
+
if (!bySlug) {
|
|
570
|
+
bySlug = new Map();
|
|
571
|
+
seriesTitleCache.set(cacheKey, bySlug);
|
|
572
|
+
}
|
|
573
|
+
if (bySlug.has(slug)) return bySlug.get(slug);
|
|
574
|
+
|
|
575
|
+
const indexInfo = resolveSeriesIndexInfo(slug);
|
|
576
|
+
if (!indexInfo) {
|
|
577
|
+
bySlug.set(slug, undefined);
|
|
578
|
+
return undefined;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
if (indexInfo.format === 'rst') {
|
|
582
|
+
const parsed = parseRstDocument(readUtf8File(indexInfo.fullPath));
|
|
583
|
+
if (parsed.metadata.draft === true) {
|
|
584
|
+
bySlug.set(slug, undefined);
|
|
585
|
+
return undefined;
|
|
586
|
+
}
|
|
587
|
+
bySlug.set(slug, parsed.title);
|
|
588
|
+
return parsed.title;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
const { data } = matter(readUtf8File(indexInfo.fullPath));
|
|
592
|
+
if (data.draft === true) {
|
|
593
|
+
bySlug.set(slug, undefined);
|
|
594
|
+
return undefined;
|
|
595
|
+
}
|
|
596
|
+
const title = typeof data.title === 'string' ? data.title : undefined;
|
|
597
|
+
bySlug.set(slug, title);
|
|
598
|
+
return title;
|
|
235
599
|
}
|
|
236
600
|
|
|
237
601
|
function parseMarkdownFile(fullPath: string, slug: string, dateFromFileName?: string, seriesName?: string): PostData {
|
|
@@ -276,11 +640,12 @@ function parseMarkdownFile(fullPath: string, slug: string, dateFromFileName?: st
|
|
|
276
640
|
}
|
|
277
641
|
|
|
278
642
|
const excerpt = data.excerpt || generateExcerpt(contentWithoutH1);
|
|
279
|
-
const
|
|
280
|
-
|
|
643
|
+
const readingMinutes = calculateReadingMinutes(contentWithoutH1);
|
|
644
|
+
const wordCount = calculateWordCount(contentWithoutH1);
|
|
645
|
+
|
|
281
646
|
let date = data.date;
|
|
282
647
|
if (!date && dateFromFileName) date = dateFromFileName;
|
|
283
|
-
if (!date) date =
|
|
648
|
+
if (!date) date = fs.statSync(fullPath).mtime.toISOString().split('T')[0];
|
|
284
649
|
|
|
285
650
|
const headings = getHeadings(content);
|
|
286
651
|
|
|
@@ -315,15 +680,189 @@ function parseMarkdownFile(fullPath: string, slug: string, dateFromFileName?: st
|
|
|
315
680
|
items: data.items as CollectionItem[] | undefined,
|
|
316
681
|
externalLinks: data.externalLinks,
|
|
317
682
|
redirectFrom: data.redirectFrom,
|
|
318
|
-
|
|
683
|
+
readingMinutes,
|
|
684
|
+
wordCount,
|
|
319
685
|
content: contentWithoutH1,
|
|
320
686
|
headings,
|
|
321
687
|
imageBaseSlug,
|
|
688
|
+
sourceFormat: 'markdown',
|
|
322
689
|
};
|
|
323
690
|
}
|
|
324
691
|
|
|
692
|
+
export function parseMarkdownFileForTests(
|
|
693
|
+
fullPath: string,
|
|
694
|
+
slug: string,
|
|
695
|
+
dateFromFileName?: string,
|
|
696
|
+
seriesName?: string,
|
|
697
|
+
): PostData {
|
|
698
|
+
return parseMarkdownFile(fullPath, slug, dateFromFileName, seriesName);
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
function parseRstFile(
|
|
702
|
+
fullPath: string,
|
|
703
|
+
slug: string,
|
|
704
|
+
dateFromFileName?: string,
|
|
705
|
+
seriesName?: string,
|
|
706
|
+
preRendered?: RenderedRstDocument,
|
|
707
|
+
): PostData {
|
|
708
|
+
try {
|
|
709
|
+
const imageBaseSlug = getRstImageBaseSlug(fullPath, slug);
|
|
710
|
+
const fileContents = fs.readFileSync(fullPath, 'utf8');
|
|
711
|
+
|
|
712
|
+
let parsedTitle: string;
|
|
713
|
+
let parsedBody: string;
|
|
714
|
+
let parsedText: string | undefined;
|
|
715
|
+
let parsedHeadings: Heading[];
|
|
716
|
+
let parsedExcerpt: string;
|
|
717
|
+
let parsedReadingMinutes: number;
|
|
718
|
+
let parsedWordCount: number;
|
|
719
|
+
let parsedHtml: string | undefined;
|
|
720
|
+
let data: ReturnType<typeof parseRstDocument>['metadata'];
|
|
721
|
+
try {
|
|
722
|
+
if (preRendered) {
|
|
723
|
+
const rendered = preRendered;
|
|
724
|
+
parsedTitle = rendered.title;
|
|
725
|
+
parsedBody = rendered.text;
|
|
726
|
+
parsedText = rendered.text;
|
|
727
|
+
parsedHeadings = rendered.headings;
|
|
728
|
+
parsedExcerpt = rendered.excerpt;
|
|
729
|
+
parsedReadingMinutes = rendered.readingMinutes;
|
|
730
|
+
parsedWordCount = rendered.wordCount;
|
|
731
|
+
parsedHtml = rendered.html;
|
|
732
|
+
data = rendered.metadata;
|
|
733
|
+
} else if (shouldUsePythonRstRenderer() && pythonRstRendererAvailable !== false) {
|
|
734
|
+
const rendered = renderRstFile(fullPath, imageBaseSlug);
|
|
735
|
+
pythonRstRendererAvailable = true;
|
|
736
|
+
parsedTitle = rendered.title;
|
|
737
|
+
parsedBody = rendered.text;
|
|
738
|
+
parsedText = rendered.text;
|
|
739
|
+
parsedHeadings = rendered.headings;
|
|
740
|
+
parsedExcerpt = rendered.excerpt;
|
|
741
|
+
parsedReadingMinutes = rendered.readingMinutes;
|
|
742
|
+
parsedWordCount = rendered.wordCount;
|
|
743
|
+
parsedHtml = rendered.html;
|
|
744
|
+
data = rendered.metadata;
|
|
745
|
+
} else {
|
|
746
|
+
throw new Error('__RST_FALLBACK__');
|
|
747
|
+
}
|
|
748
|
+
} catch (error) {
|
|
749
|
+
if (!isPythonRuntimeUnavailable(error)) {
|
|
750
|
+
throw error;
|
|
751
|
+
}
|
|
752
|
+
if (pythonRstRendererAvailable !== false) {
|
|
753
|
+
pythonRstRendererAvailable = false;
|
|
754
|
+
}
|
|
755
|
+
const parsed = parseRstDocument(fileContents);
|
|
756
|
+
parsedTitle = parsed.title;
|
|
757
|
+
parsedBody = parsed.body;
|
|
758
|
+
parsedHeadings = parsed.headings;
|
|
759
|
+
parsedExcerpt = parsed.excerpt;
|
|
760
|
+
parsedReadingMinutes = parsed.readingMinutes;
|
|
761
|
+
parsedWordCount = parsed.wordCount;
|
|
762
|
+
data = parsed.metadata;
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
const effectiveSeriesSlug = data.series || seriesName;
|
|
766
|
+
let authors: string[] = [];
|
|
767
|
+
if (data.authors && data.authors.length > 0) {
|
|
768
|
+
authors = data.authors;
|
|
769
|
+
} else if (data.author) {
|
|
770
|
+
authors = [data.author];
|
|
771
|
+
} else {
|
|
772
|
+
if (effectiveSeriesSlug) {
|
|
773
|
+
const seriesAuthors = getSeriesAuthors(effectiveSeriesSlug);
|
|
774
|
+
if (seriesAuthors) authors = seriesAuthors;
|
|
775
|
+
}
|
|
776
|
+
if (authors.length === 0) {
|
|
777
|
+
const defaultAuthors = siteConfig.posts?.authors?.default;
|
|
778
|
+
if (defaultAuthors && defaultAuthors.length > 0) {
|
|
779
|
+
authors = defaultAuthors;
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
let date = data.date;
|
|
785
|
+
if (!date && dateFromFileName) date = dateFromFileName;
|
|
786
|
+
if (!date) date = fs.statSync(fullPath).mtime.toISOString().split('T')[0];
|
|
787
|
+
|
|
788
|
+
let coverImage = data.coverImage;
|
|
789
|
+
if (coverImage && !coverImage.startsWith('http') && !coverImage.startsWith('/') && !coverImage.startsWith('text:')) {
|
|
790
|
+
const cleanPath = coverImage.replace(/^\.\//, '');
|
|
791
|
+
coverImage = `/${imageBaseSlug}/${cleanPath}`;
|
|
792
|
+
}
|
|
793
|
+
const toctreePosts = isSeriesIndexRst(fullPath, slug, seriesName)
|
|
794
|
+
? extractRstToctreePosts(fileContents)
|
|
795
|
+
: [];
|
|
796
|
+
const seriesPosts = data.posts && data.posts.length > 0
|
|
797
|
+
? data.posts
|
|
798
|
+
: ((data.sort === undefined || data.sort === 'manual') && toctreePosts.length > 0 ? toctreePosts : undefined);
|
|
799
|
+
const sort = data.sort ?? (seriesPosts ? 'manual' : 'date-desc');
|
|
800
|
+
|
|
801
|
+
return {
|
|
802
|
+
slug,
|
|
803
|
+
title: parsedTitle,
|
|
804
|
+
subtitle: data.subtitle,
|
|
805
|
+
date,
|
|
806
|
+
excerpt: data.excerpt || parsedExcerpt,
|
|
807
|
+
category: data.category ?? 'Uncategorized',
|
|
808
|
+
tags: data.tags ?? [],
|
|
809
|
+
authors,
|
|
810
|
+
layout: data.layout ?? 'post',
|
|
811
|
+
series: effectiveSeriesSlug,
|
|
812
|
+
seriesTitle: effectiveSeriesSlug ? getSeriesTitle(effectiveSeriesSlug) : undefined,
|
|
813
|
+
coverImage,
|
|
814
|
+
sort,
|
|
815
|
+
posts: seriesPosts,
|
|
816
|
+
type: data.type,
|
|
817
|
+
featured: data.featured ?? false,
|
|
818
|
+
pinned: data.pinned ?? false,
|
|
819
|
+
draft: data.draft ?? false,
|
|
820
|
+
latex: data.latex ?? false,
|
|
821
|
+
toc: data.toc ?? true,
|
|
822
|
+
commentable: data.commentable,
|
|
823
|
+
redirectFrom: data.redirectFrom ?? [],
|
|
824
|
+
readingMinutes: parsedReadingMinutes,
|
|
825
|
+
wordCount: parsedWordCount,
|
|
826
|
+
content: parsedBody,
|
|
827
|
+
renderedHtml: parsedHtml,
|
|
828
|
+
plainText: parsedText,
|
|
829
|
+
headings: parsedHeadings,
|
|
830
|
+
imageBaseSlug,
|
|
831
|
+
sourceFormat: 'rst',
|
|
832
|
+
};
|
|
833
|
+
} catch (error) {
|
|
834
|
+
if (error instanceof RstParseError) {
|
|
835
|
+
throw new RstParseError(`${error.message} (${fullPath})`);
|
|
836
|
+
}
|
|
837
|
+
throw error;
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
export function parseRstFileForTests(
|
|
842
|
+
fullPath: string,
|
|
843
|
+
slug: string,
|
|
844
|
+
dateFromFileName?: string,
|
|
845
|
+
seriesName?: string,
|
|
846
|
+
preRendered?: RenderedRstDocument,
|
|
847
|
+
): PostData {
|
|
848
|
+
return parseRstFile(fullPath, slug, dateFromFileName, seriesName, preRendered);
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
export function resetPythonRstRendererAvailabilityForTests(value: boolean | null = null): void {
|
|
852
|
+
pythonRstRendererAvailable = value;
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
export function getPythonRstRendererAvailabilityForTests(): boolean | null {
|
|
856
|
+
return pythonRstRendererAvailable;
|
|
857
|
+
}
|
|
858
|
+
|
|
325
859
|
export function getAllPosts(): PostData[] {
|
|
860
|
+
const cacheKey = getCacheEnvKey();
|
|
861
|
+
const cached = postsCache.get(cacheKey);
|
|
862
|
+
if (cached) return cached;
|
|
863
|
+
|
|
326
864
|
const allPostsData: PostData[] = [];
|
|
865
|
+
const pendingRstPosts: PendingRstPostEntry[] = [];
|
|
327
866
|
|
|
328
867
|
// Helper to process a directory
|
|
329
868
|
const processDirectory = (dir: string, isSeriesDir: boolean = false) => {
|
|
@@ -336,81 +875,29 @@ export function getAllPosts(): PostData[] {
|
|
|
336
875
|
let slug = '';
|
|
337
876
|
let dateFromFileName = undefined;
|
|
338
877
|
|
|
339
|
-
const dateRegex = /^(\d{4}-\d{2}-\d{2})-(.*)$/;
|
|
340
878
|
const rawName = item.name.replace(/\.mdx?$/, '');
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
if (match) {
|
|
344
|
-
dateFromFileName = match[1];
|
|
345
|
-
if (siteConfig.posts?.includeDateInUrl) {
|
|
346
|
-
slug = rawName;
|
|
347
|
-
} else {
|
|
348
|
-
slug = match[2];
|
|
349
|
-
}
|
|
350
|
-
} else {
|
|
351
|
-
slug = rawName;
|
|
352
|
-
}
|
|
879
|
+
({ slug, dateFromFileName } = parseSlugAndDate(rawName));
|
|
353
880
|
|
|
354
881
|
// Handle Series Directory logic
|
|
355
882
|
if (isSeriesDir) {
|
|
356
883
|
if (item.isDirectory()) {
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
allPostsData.push(parseMarkdownFile(
|
|
377
|
-
path.join(seriesPath, sItem.name),
|
|
378
|
-
sSlug,
|
|
379
|
-
sDate,
|
|
380
|
-
seriesSlug
|
|
381
|
-
));
|
|
382
|
-
}
|
|
383
|
-
// 2. Folder-based posts: series/slug/post-folder/index.mdx
|
|
384
|
-
else if (sItem.isDirectory()) {
|
|
385
|
-
const postFolder = path.join(seriesPath, sItem.name);
|
|
386
|
-
const postIndexMdx = path.join(postFolder, 'index.mdx');
|
|
387
|
-
const postIndexMd = path.join(postFolder, 'index.md');
|
|
388
|
-
let postFullPath = '';
|
|
389
|
-
|
|
390
|
-
if (fs.existsSync(postIndexMdx)) postFullPath = postIndexMdx;
|
|
391
|
-
else if (fs.existsSync(postIndexMd)) postFullPath = postIndexMd;
|
|
392
|
-
|
|
393
|
-
if (postFullPath) {
|
|
394
|
-
// Handle date prefix in folder name
|
|
395
|
-
const sMatch = sItem.name.match(dateRegex);
|
|
396
|
-
let sSlug = sItem.name;
|
|
397
|
-
let sDate = undefined;
|
|
398
|
-
|
|
399
|
-
if (sMatch) {
|
|
400
|
-
sDate = sMatch[1];
|
|
401
|
-
sSlug = siteConfig.posts?.includeDateInUrl ? sItem.name : sMatch[2];
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
allPostsData.push(parseMarkdownFile(
|
|
405
|
-
postFullPath,
|
|
406
|
-
sSlug,
|
|
407
|
-
sDate,
|
|
408
|
-
seriesSlug
|
|
409
|
-
));
|
|
410
|
-
}
|
|
411
|
-
}
|
|
412
|
-
});
|
|
413
|
-
return; // Processed this series folder
|
|
884
|
+
const seriesSlug = item.name;
|
|
885
|
+
const indexInfo = resolveSeriesIndexInfo(seriesSlug);
|
|
886
|
+
if (!indexInfo) return;
|
|
887
|
+
|
|
888
|
+
getSeriesContentEntries(seriesSlug).forEach(entry => {
|
|
889
|
+
if (indexInfo.format === 'rst') {
|
|
890
|
+
pendingRstPosts.push({
|
|
891
|
+
fullPath: entry.fullPath,
|
|
892
|
+
slug: entry.slug,
|
|
893
|
+
dateFromFileName: entry.dateFromFileName,
|
|
894
|
+
seriesSlug,
|
|
895
|
+
});
|
|
896
|
+
} else {
|
|
897
|
+
allPostsData.push(parseMarkdownFile(entry.fullPath, entry.slug, entry.dateFromFileName, seriesSlug));
|
|
898
|
+
}
|
|
899
|
+
});
|
|
900
|
+
return;
|
|
414
901
|
}
|
|
415
902
|
}
|
|
416
903
|
|
|
@@ -434,7 +921,41 @@ export function getAllPosts(): PostData[] {
|
|
|
434
921
|
processDirectory(contentDirectory);
|
|
435
922
|
processDirectory(seriesDirectory, true);
|
|
436
923
|
|
|
437
|
-
|
|
924
|
+
if (pendingRstPosts.length > 0) {
|
|
925
|
+
let batchRenderedByFile: Map<string, RenderedRstDocument> | null = null;
|
|
926
|
+
|
|
927
|
+
if (shouldUsePythonRstRenderer() && pythonRstRendererAvailable !== false) {
|
|
928
|
+
try {
|
|
929
|
+
batchRenderedByFile = renderRstFilesBatch(
|
|
930
|
+
pendingRstPosts.map(entry => ({
|
|
931
|
+
file: entry.fullPath,
|
|
932
|
+
imageBaseSlug: getRstImageBaseSlug(entry.fullPath, entry.slug),
|
|
933
|
+
}))
|
|
934
|
+
);
|
|
935
|
+
pythonRstRendererAvailable = true;
|
|
936
|
+
} catch (error) {
|
|
937
|
+
if (isPythonRuntimeUnavailable(error)) {
|
|
938
|
+
pythonRstRendererAvailable = false;
|
|
939
|
+
} else {
|
|
940
|
+
throw error;
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
pendingRstPosts.forEach(entry => {
|
|
946
|
+
allPostsData.push(
|
|
947
|
+
parseRstFile(
|
|
948
|
+
entry.fullPath,
|
|
949
|
+
entry.slug,
|
|
950
|
+
entry.dateFromFileName,
|
|
951
|
+
entry.seriesSlug,
|
|
952
|
+
batchRenderedByFile?.get(entry.fullPath),
|
|
953
|
+
)
|
|
954
|
+
);
|
|
955
|
+
});
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
const result = allPostsData
|
|
438
959
|
.filter(post => {
|
|
439
960
|
if (post.category === 'Page') return false;
|
|
440
961
|
|
|
@@ -450,6 +971,8 @@ export function getAllPosts(): PostData[] {
|
|
|
450
971
|
return true;
|
|
451
972
|
})
|
|
452
973
|
.sort((a, b) => (a.date < b.date ? 1 : -1));
|
|
974
|
+
postsCache.set(cacheKey, result);
|
|
975
|
+
return result;
|
|
453
976
|
}
|
|
454
977
|
|
|
455
978
|
/**
|
|
@@ -463,109 +986,8 @@ export function getListingPosts(): PostData[] {
|
|
|
463
986
|
return getAllPosts().filter(p => !p.series || !excluded.has(p.series));
|
|
464
987
|
}
|
|
465
988
|
|
|
466
|
-
function findPostFile(name: string, targetSlug: string): PostData | null {
|
|
467
|
-
// Check standard posts
|
|
468
|
-
let fullPath = path.join(contentDirectory, `${name}.mdx`);
|
|
469
|
-
if (fs.existsSync(fullPath)) return parseMarkdownFile(fullPath, targetSlug);
|
|
470
|
-
|
|
471
|
-
fullPath = path.join(contentDirectory, `${name}.md`);
|
|
472
|
-
if (fs.existsSync(fullPath)) return parseMarkdownFile(fullPath, targetSlug);
|
|
473
|
-
|
|
474
|
-
if (fs.existsSync(path.join(contentDirectory, name))) {
|
|
475
|
-
fullPath = path.join(contentDirectory, name, 'index.mdx');
|
|
476
|
-
if (fs.existsSync(fullPath)) return parseMarkdownFile(fullPath, targetSlug);
|
|
477
|
-
|
|
478
|
-
fullPath = path.join(contentDirectory, name, 'index.md');
|
|
479
|
-
if (fs.existsSync(fullPath)) return parseMarkdownFile(fullPath, targetSlug);
|
|
480
|
-
}
|
|
481
|
-
|
|
482
|
-
// Check series posts
|
|
483
|
-
if (fs.existsSync(seriesDirectory)) {
|
|
484
|
-
const seriesFolders = fs.readdirSync(seriesDirectory);
|
|
485
|
-
for (const folder of seriesFolders) {
|
|
486
|
-
const folderPath = path.join(seriesDirectory, folder);
|
|
487
|
-
if (!fs.statSync(folderPath).isDirectory()) continue;
|
|
488
|
-
|
|
489
|
-
// Check file-based
|
|
490
|
-
fullPath = path.join(folderPath, `${name}.mdx`);
|
|
491
|
-
if (fs.existsSync(fullPath)) return parseMarkdownFile(fullPath, targetSlug, undefined, folder);
|
|
492
|
-
|
|
493
|
-
fullPath = path.join(folderPath, `${name}.md`);
|
|
494
|
-
if (fs.existsSync(fullPath)) return parseMarkdownFile(fullPath, targetSlug, undefined, folder);
|
|
495
|
-
|
|
496
|
-
// Check folder-based
|
|
497
|
-
const postFolderPath = path.join(folderPath, name);
|
|
498
|
-
if (fs.existsSync(postFolderPath) && fs.statSync(postFolderPath).isDirectory()) {
|
|
499
|
-
fullPath = path.join(postFolderPath, 'index.mdx');
|
|
500
|
-
if (fs.existsSync(fullPath)) return parseMarkdownFile(fullPath, targetSlug, undefined, folder);
|
|
501
|
-
|
|
502
|
-
fullPath = path.join(postFolderPath, 'index.md');
|
|
503
|
-
if (fs.existsSync(fullPath)) return parseMarkdownFile(fullPath, targetSlug, undefined, folder);
|
|
504
|
-
}
|
|
505
|
-
}
|
|
506
|
-
}
|
|
507
|
-
|
|
508
|
-
return null;
|
|
509
|
-
}
|
|
510
|
-
|
|
511
989
|
export function getPostBySlug(slug: string): PostData | null {
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
if (siteConfig.posts?.includeDateInUrl) {
|
|
515
|
-
post = findPostFile(slug, slug);
|
|
516
|
-
} else {
|
|
517
|
-
post = findPostFile(slug, slug);
|
|
518
|
-
if (!post) {
|
|
519
|
-
// Search in content/posts
|
|
520
|
-
const items = fs.existsSync(contentDirectory) ? fs.readdirSync(contentDirectory) : [];
|
|
521
|
-
for (const item of items) {
|
|
522
|
-
const rawName = item.replace(/\.mdx?$/, '');
|
|
523
|
-
const dateRegex = /^(\d{4}-\d{2}-\d{2})-(.*)$/;
|
|
524
|
-
const match = rawName.match(dateRegex);
|
|
525
|
-
|
|
526
|
-
if (match && match[2] === slug) {
|
|
527
|
-
post = findPostFile(rawName, slug);
|
|
528
|
-
break;
|
|
529
|
-
}
|
|
530
|
-
}
|
|
531
|
-
|
|
532
|
-
// If not found, search in series folders
|
|
533
|
-
if (!post && fs.existsSync(seriesDirectory)) {
|
|
534
|
-
const seriesFolders = fs.readdirSync(seriesDirectory);
|
|
535
|
-
for (const folder of seriesFolders) {
|
|
536
|
-
const folderPath = path.join(seriesDirectory, folder);
|
|
537
|
-
if (!fs.statSync(folderPath).isDirectory()) continue;
|
|
538
|
-
|
|
539
|
-
const sItems = fs.readdirSync(folderPath);
|
|
540
|
-
for (const sItem of sItems) {
|
|
541
|
-
const sRawName = sItem.replace(/\.mdx?$/, '');
|
|
542
|
-
// Also check folders
|
|
543
|
-
const sDateRegex = /^(\d{4}-\d{2}-\d{2})-(.*)$/;
|
|
544
|
-
const sMatch = sRawName.match(sDateRegex);
|
|
545
|
-
|
|
546
|
-
if (sMatch && sMatch[2] === slug) {
|
|
547
|
-
post = findPostFile(sRawName, slug);
|
|
548
|
-
break;
|
|
549
|
-
}
|
|
550
|
-
}
|
|
551
|
-
if (post) break;
|
|
552
|
-
}
|
|
553
|
-
}
|
|
554
|
-
}
|
|
555
|
-
}
|
|
556
|
-
|
|
557
|
-
if (!post) return null;
|
|
558
|
-
|
|
559
|
-
if (process.env.NODE_ENV === 'production' && post.draft) {
|
|
560
|
-
return null;
|
|
561
|
-
}
|
|
562
|
-
|
|
563
|
-
if (!siteConfig.posts?.showFuturePosts) {
|
|
564
|
-
const postDate = new Date(post.date);
|
|
565
|
-
const now = new Date();
|
|
566
|
-
if (postDate > now) return null;
|
|
567
|
-
}
|
|
568
|
-
return post;
|
|
990
|
+
return getAllPosts().find(post => post.slug === slug) ?? null;
|
|
569
991
|
}
|
|
570
992
|
|
|
571
993
|
/**
|
|
@@ -621,8 +1043,12 @@ export function getPageBySlug(slug: string): PostData | null {
|
|
|
621
1043
|
}
|
|
622
1044
|
|
|
623
1045
|
export function getAllPages(): PostData[] {
|
|
1046
|
+
const cacheKey = getCacheEnvKey();
|
|
1047
|
+
const cached = pagesCache.get(cacheKey);
|
|
1048
|
+
if (cached) return cached;
|
|
1049
|
+
|
|
624
1050
|
const items = fs.readdirSync(pagesDirectory, { withFileTypes: true });
|
|
625
|
-
|
|
1051
|
+
const result = items
|
|
626
1052
|
.filter(item => {
|
|
627
1053
|
if (!item.isFile()) return false;
|
|
628
1054
|
if (!item.name.endsWith('.mdx') && !item.name.endsWith('.md')) return false;
|
|
@@ -639,6 +1065,8 @@ export function getAllPages(): PostData[] {
|
|
|
639
1065
|
const fullPath = path.join(pagesDirectory, item.name);
|
|
640
1066
|
return attachContentLocales(parseMarkdownFile(fullPath, slug), slug);
|
|
641
1067
|
});
|
|
1068
|
+
pagesCache.set(cacheKey, result);
|
|
1069
|
+
return result;
|
|
642
1070
|
}
|
|
643
1071
|
|
|
644
1072
|
export function getPostsByTag(tag: string): PostData[] {
|
|
@@ -661,6 +1089,10 @@ export function getFlowTags(): Record<string, number> {
|
|
|
661
1089
|
}
|
|
662
1090
|
|
|
663
1091
|
export function getAllTags(): Record<string, number> {
|
|
1092
|
+
const cacheKey = getCacheEnvKey();
|
|
1093
|
+
const cached = tagsCache.get(cacheKey);
|
|
1094
|
+
if (cached) return cached;
|
|
1095
|
+
|
|
664
1096
|
const allPosts = getAllPosts();
|
|
665
1097
|
const allFlows = getAllFlows();
|
|
666
1098
|
const allNotes = getAllNotes();
|
|
@@ -696,6 +1128,7 @@ export function getAllTags(): Record<string, number> {
|
|
|
696
1128
|
for (const [key, count] of Object.entries(counts)) {
|
|
697
1129
|
result[display[key]] = count;
|
|
698
1130
|
}
|
|
1131
|
+
tagsCache.set(cacheKey, result);
|
|
699
1132
|
return result;
|
|
700
1133
|
}
|
|
701
1134
|
|
|
@@ -717,6 +1150,10 @@ export function getAuthorSlug(author: string): string {
|
|
|
717
1150
|
}
|
|
718
1151
|
|
|
719
1152
|
export function getAllAuthors(): Record<string, number> {
|
|
1153
|
+
const cacheKey = getCacheEnvKey();
|
|
1154
|
+
const cached = authorsCache.get(cacheKey);
|
|
1155
|
+
if (cached) return cached;
|
|
1156
|
+
|
|
720
1157
|
const allPosts = getAllPosts();
|
|
721
1158
|
const authors: Record<string, number> = {};
|
|
722
1159
|
|
|
@@ -729,7 +1166,7 @@ export function getAllAuthors(): Record<string, number> {
|
|
|
729
1166
|
}
|
|
730
1167
|
});
|
|
731
1168
|
});
|
|
732
|
-
|
|
1169
|
+
authorsCache.set(cacheKey, authors);
|
|
733
1170
|
return authors;
|
|
734
1171
|
}
|
|
735
1172
|
|
|
@@ -747,6 +1184,16 @@ export function resolveAuthorParam(authorParam: string): string | null {
|
|
|
747
1184
|
}
|
|
748
1185
|
|
|
749
1186
|
export function getRelatedPosts(currentSlug: string, limit: number = 3): PostData[] {
|
|
1187
|
+
const cacheKey = getCacheEnvKey();
|
|
1188
|
+
let bySlug = relatedPostsCache.get(cacheKey);
|
|
1189
|
+
if (!bySlug) {
|
|
1190
|
+
bySlug = new Map();
|
|
1191
|
+
relatedPostsCache.set(cacheKey, bySlug);
|
|
1192
|
+
}
|
|
1193
|
+
const cacheId = `${currentSlug}:${limit}`;
|
|
1194
|
+
const cached = bySlug.get(cacheId);
|
|
1195
|
+
if (cached) return cached;
|
|
1196
|
+
|
|
750
1197
|
const allPosts = getAllPosts();
|
|
751
1198
|
const currentPost = allPosts.find(p => p.slug === currentSlug);
|
|
752
1199
|
|
|
@@ -770,10 +1217,20 @@ export function getRelatedPosts(currentSlug: string, limit: number = 3): PostDat
|
|
|
770
1217
|
.slice(0, limit)
|
|
771
1218
|
.map(item => item.post);
|
|
772
1219
|
|
|
1220
|
+
bySlug.set(cacheId, related);
|
|
773
1221
|
return related;
|
|
774
1222
|
}
|
|
775
1223
|
|
|
776
1224
|
export function getSeriesPosts(seriesName: string): PostData[] {
|
|
1225
|
+
const cacheKey = getCacheEnvKey();
|
|
1226
|
+
let bySeries = seriesPostsCache.get(cacheKey);
|
|
1227
|
+
if (!bySeries) {
|
|
1228
|
+
bySeries = new Map();
|
|
1229
|
+
seriesPostsCache.set(cacheKey, bySeries);
|
|
1230
|
+
}
|
|
1231
|
+
const cached = bySeries.get(seriesName);
|
|
1232
|
+
if (cached) return cached;
|
|
1233
|
+
|
|
777
1234
|
const seriesSlug = seriesName;
|
|
778
1235
|
const seriesData = getSeriesData(seriesSlug);
|
|
779
1236
|
|
|
@@ -798,10 +1255,15 @@ export function getSeriesPosts(seriesName: string): PostData[] {
|
|
|
798
1255
|
}
|
|
799
1256
|
}
|
|
800
1257
|
|
|
1258
|
+
bySeries.set(seriesName, posts);
|
|
801
1259
|
return posts;
|
|
802
1260
|
}
|
|
803
1261
|
|
|
804
1262
|
export function getAllSeries(): Record<string, PostData[]> {
|
|
1263
|
+
const cacheKey = getCacheEnvKey();
|
|
1264
|
+
const cached = allSeriesCache.get(cacheKey);
|
|
1265
|
+
if (cached) return cached;
|
|
1266
|
+
|
|
805
1267
|
const allPosts = getAllPosts();
|
|
806
1268
|
const series: Record<string, PostData[]> = {};
|
|
807
1269
|
const seriesSet = new Set<string>();
|
|
@@ -834,25 +1296,89 @@ export function getAllSeries(): Record<string, PostData[]> {
|
|
|
834
1296
|
: getSeriesPosts(slug);
|
|
835
1297
|
});
|
|
836
1298
|
|
|
1299
|
+
allSeriesCache.set(cacheKey, series);
|
|
837
1300
|
return series;
|
|
838
1301
|
}
|
|
839
1302
|
|
|
1303
|
+
export function getSeriesLatestPostDate(slug: string): string {
|
|
1304
|
+
const cacheKey = getCacheEnvKey();
|
|
1305
|
+
let bySlug = seriesLatestDateCache.get(cacheKey);
|
|
1306
|
+
if (!bySlug) {
|
|
1307
|
+
bySlug = new Map();
|
|
1308
|
+
seriesLatestDateCache.set(cacheKey, bySlug);
|
|
1309
|
+
}
|
|
1310
|
+
const cached = bySlug.get(slug);
|
|
1311
|
+
if (cached !== undefined) return cached;
|
|
1312
|
+
|
|
1313
|
+
const seriesData = getSeriesData(slug);
|
|
1314
|
+
const posts = seriesData?.type === 'collection' ? getCollectionPosts(slug) : getSeriesPosts(slug);
|
|
1315
|
+
const latestPostDate = posts.reduce((latest, post) => (post.date > latest ? post.date : latest), '');
|
|
1316
|
+
const resolved = latestPostDate || seriesData?.date || '';
|
|
1317
|
+
|
|
1318
|
+
bySlug.set(slug, resolved);
|
|
1319
|
+
return resolved;
|
|
1320
|
+
}
|
|
1321
|
+
|
|
840
1322
|
export function getFeaturedPosts(): PostData[] {
|
|
841
|
-
const
|
|
842
|
-
|
|
1323
|
+
const cacheKey = getCacheEnvKey();
|
|
1324
|
+
const cached = featuredPostsCache.get(cacheKey);
|
|
1325
|
+
if (cached) return cached;
|
|
1326
|
+
const result = getAllPosts().filter(post => post.featured);
|
|
1327
|
+
featuredPostsCache.set(cacheKey, result);
|
|
1328
|
+
return result;
|
|
843
1329
|
}
|
|
844
1330
|
|
|
845
1331
|
export function getAdjacentPosts(slug: string): { prev: PostData | null; next: PostData | null } {
|
|
1332
|
+
const cacheKey = getCacheEnvKey();
|
|
1333
|
+
let bySlug = adjacentPostsCache.get(cacheKey);
|
|
1334
|
+
if (!bySlug) {
|
|
1335
|
+
bySlug = new Map();
|
|
1336
|
+
adjacentPostsCache.set(cacheKey, bySlug);
|
|
1337
|
+
}
|
|
1338
|
+
const currentPost = getPostBySlug(slug);
|
|
1339
|
+
if (currentPost?.series) {
|
|
1340
|
+
const seriesCacheKey = `${currentPost.series}/${slug}`;
|
|
1341
|
+
const cachedSeries = bySlug.get(seriesCacheKey);
|
|
1342
|
+
if (cachedSeries) return cachedSeries;
|
|
1343
|
+
|
|
1344
|
+
const seriesData = getSeriesData(currentPost.series);
|
|
1345
|
+
if (seriesData?.type !== 'collection') {
|
|
1346
|
+
const seriesPosts = getSeriesPosts(currentPost.series);
|
|
1347
|
+
const seriesIndex = seriesPosts.findIndex(post => post.slug === slug);
|
|
1348
|
+
if (seriesIndex !== -1) {
|
|
1349
|
+
const seriesResult = {
|
|
1350
|
+
prev: seriesIndex > 0 ? seriesPosts[seriesIndex - 1] : null,
|
|
1351
|
+
next: seriesIndex < seriesPosts.length - 1 ? seriesPosts[seriesIndex + 1] : null,
|
|
1352
|
+
};
|
|
1353
|
+
bySlug.set(seriesCacheKey, seriesResult);
|
|
1354
|
+
return seriesResult;
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
const cached = bySlug.get(slug);
|
|
1360
|
+
if (cached) return cached;
|
|
1361
|
+
|
|
846
1362
|
const allPosts = getAllPosts(); // sorted desc by date (newest first)
|
|
847
1363
|
const index = allPosts.findIndex(p => p.slug === slug);
|
|
848
|
-
if (index === -1)
|
|
849
|
-
|
|
1364
|
+
if (index === -1) {
|
|
1365
|
+
const empty = { prev: null, next: null };
|
|
1366
|
+
bySlug.set(slug, empty);
|
|
1367
|
+
return empty;
|
|
1368
|
+
}
|
|
1369
|
+
const result = {
|
|
850
1370
|
prev: index < allPosts.length - 1 ? allPosts[index + 1] : null, // older post
|
|
851
1371
|
next: index > 0 ? allPosts[index - 1] : null, // newer post
|
|
852
1372
|
};
|
|
1373
|
+
bySlug.set(slug, result);
|
|
1374
|
+
return result;
|
|
853
1375
|
}
|
|
854
1376
|
|
|
855
1377
|
export function getFeaturedSeries(): Record<string, PostData[]> {
|
|
1378
|
+
const cacheKey = getCacheEnvKey();
|
|
1379
|
+
const cached = featuredSeriesCache.get(cacheKey);
|
|
1380
|
+
if (cached) return cached;
|
|
1381
|
+
|
|
856
1382
|
const allSeries = getAllSeries();
|
|
857
1383
|
const featuredSeries: Record<string, PostData[]> = {};
|
|
858
1384
|
|
|
@@ -863,25 +1389,47 @@ export function getFeaturedSeries(): Record<string, PostData[]> {
|
|
|
863
1389
|
}
|
|
864
1390
|
});
|
|
865
1391
|
|
|
1392
|
+
featuredSeriesCache.set(cacheKey, featuredSeries);
|
|
866
1393
|
return featuredSeries;
|
|
867
1394
|
}
|
|
868
1395
|
|
|
869
1396
|
export function getSeriesData(slug: string): PostData | null {
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
1397
|
+
const cacheKey = getCacheEnvKey();
|
|
1398
|
+
let bySlug = seriesDataCache.get(cacheKey);
|
|
1399
|
+
if (!bySlug) {
|
|
1400
|
+
bySlug = new Map();
|
|
1401
|
+
seriesDataCache.set(cacheKey, bySlug);
|
|
1402
|
+
}
|
|
1403
|
+
if (bySlug.has(slug)) return bySlug.get(slug) ?? null;
|
|
873
1404
|
|
|
874
|
-
|
|
875
|
-
if (
|
|
876
|
-
|
|
877
|
-
|
|
1405
|
+
const indexInfo = resolveSeriesIndexInfo(slug);
|
|
1406
|
+
if (!indexInfo) {
|
|
1407
|
+
bySlug.set(slug, null);
|
|
1408
|
+
return null;
|
|
1409
|
+
}
|
|
878
1410
|
|
|
879
|
-
|
|
1411
|
+
const result = indexInfo.format === 'rst'
|
|
1412
|
+
? parseRstFile(indexInfo.fullPath, slug, undefined, slug)
|
|
1413
|
+
: parseMarkdownFile(indexInfo.fullPath, slug, undefined, slug);
|
|
1414
|
+
bySlug.set(slug, result);
|
|
1415
|
+
return result;
|
|
880
1416
|
}
|
|
881
1417
|
|
|
882
1418
|
export function getCollectionPosts(collectionSlug: string): PostData[] {
|
|
1419
|
+
const cacheKey = getCacheEnvKey();
|
|
1420
|
+
let bySlug = collectionPostsCache.get(cacheKey);
|
|
1421
|
+
if (!bySlug) {
|
|
1422
|
+
bySlug = new Map();
|
|
1423
|
+
collectionPostsCache.set(cacheKey, bySlug);
|
|
1424
|
+
}
|
|
1425
|
+
const cached = bySlug.get(collectionSlug);
|
|
1426
|
+
if (cached) return cached;
|
|
1427
|
+
|
|
883
1428
|
const data = getSeriesData(collectionSlug);
|
|
884
|
-
if (data?.type !== 'collection' || !data.items)
|
|
1429
|
+
if (data?.type !== 'collection' || !data.items) {
|
|
1430
|
+
bySlug.set(collectionSlug, []);
|
|
1431
|
+
return [];
|
|
1432
|
+
}
|
|
885
1433
|
|
|
886
1434
|
const getCollectionKey = (post: PostData) =>
|
|
887
1435
|
post.series ? `${post.series}/${post.slug}` : `posts/${post.slug}`;
|
|
@@ -890,7 +1438,7 @@ export function getCollectionPosts(collectionSlug: string): PostData[] {
|
|
|
890
1438
|
const postIndex = new Map(allPosts.map((post) => [getCollectionKey(post), post]));
|
|
891
1439
|
const seen = new Set<string>();
|
|
892
1440
|
|
|
893
|
-
|
|
1441
|
+
const result = data.items
|
|
894
1442
|
.flatMap(item => {
|
|
895
1443
|
if ('series' in item) {
|
|
896
1444
|
const posts = getSeriesPosts(item.series);
|
|
@@ -909,9 +1457,20 @@ export function getCollectionPosts(collectionSlug: string): PostData[] {
|
|
|
909
1457
|
seen.add(key);
|
|
910
1458
|
return true;
|
|
911
1459
|
});
|
|
1460
|
+
bySlug.set(collectionSlug, result);
|
|
1461
|
+
return result;
|
|
912
1462
|
}
|
|
913
1463
|
|
|
914
1464
|
export function getCollectionsForPost(postSlug: string): CollectionContext[] {
|
|
1465
|
+
const cacheKey = getCacheEnvKey();
|
|
1466
|
+
let bySlug = collectionsForPostCache.get(cacheKey);
|
|
1467
|
+
if (!bySlug) {
|
|
1468
|
+
bySlug = new Map();
|
|
1469
|
+
collectionsForPostCache.set(cacheKey, bySlug);
|
|
1470
|
+
}
|
|
1471
|
+
const cached = bySlug.get(postSlug);
|
|
1472
|
+
if (cached) return cached;
|
|
1473
|
+
|
|
915
1474
|
if (!fs.existsSync(seriesDirectory)) return [];
|
|
916
1475
|
const seriesFolders = fs.readdirSync(seriesDirectory, { withFileTypes: true });
|
|
917
1476
|
const results: CollectionContext[] = [];
|
|
@@ -927,6 +1486,7 @@ export function getCollectionsForPost(postSlug: string): CollectionContext[] {
|
|
|
927
1486
|
}
|
|
928
1487
|
}
|
|
929
1488
|
|
|
1489
|
+
bySlug.set(postSlug, results);
|
|
930
1490
|
return results;
|
|
931
1491
|
}
|
|
932
1492
|
|
|
@@ -935,14 +1495,32 @@ export function getCollectionsForPost(postSlug: string): CollectionContext[] {
|
|
|
935
1495
|
export interface BookChapterEntry {
|
|
936
1496
|
title: string;
|
|
937
1497
|
id: string;
|
|
1498
|
+
/** Legacy single-level grouping; set when the chapter sits under a `{ part, chapters }` item. */
|
|
938
1499
|
part?: string;
|
|
1500
|
+
/** Deepest section title above this chapter (last element of sectionPath). */
|
|
1501
|
+
section?: string;
|
|
1502
|
+
/** Full ancestry of section titles from outermost to innermost. */
|
|
1503
|
+
sectionPath?: string[];
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
export interface BookChapterRef {
|
|
1507
|
+
title: string;
|
|
1508
|
+
id: string;
|
|
939
1509
|
}
|
|
940
1510
|
|
|
941
1511
|
export interface BookTocPart {
|
|
942
1512
|
part: string;
|
|
943
|
-
chapters:
|
|
1513
|
+
chapters: BookChapterRef[];
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1516
|
+
/** Nested grouping. `items` may recurse into further sections or hold leaf chapter refs. */
|
|
1517
|
+
export interface BookTocSection {
|
|
1518
|
+
section: string;
|
|
1519
|
+
collapsible?: boolean;
|
|
1520
|
+
items: Array<BookTocSection | BookChapterRef>;
|
|
944
1521
|
}
|
|
945
|
-
|
|
1522
|
+
|
|
1523
|
+
export type BookTocItem = BookTocPart | BookTocSection | BookChapterRef;
|
|
946
1524
|
|
|
947
1525
|
export interface BookData {
|
|
948
1526
|
title: string;
|
|
@@ -953,6 +1531,16 @@ export interface BookData {
|
|
|
953
1531
|
featured: boolean;
|
|
954
1532
|
draft: boolean;
|
|
955
1533
|
authors: string[];
|
|
1534
|
+
/** Book-level LaTeX flag — when true, all chapters render math even if their
|
|
1535
|
+
* own frontmatter omits `latex: true`. Cheaper for math-heavy books than
|
|
1536
|
+
* annotating every chapter file. */
|
|
1537
|
+
latex: boolean;
|
|
1538
|
+
/** Whether the chapter-page header renders the chapter's `excerpt`. Defaults
|
|
1539
|
+
* to false: the typical case is that a chapter opens with its own lede
|
|
1540
|
+
* paragraph, and an excerpt line above it just duplicates that text in the
|
|
1541
|
+
* header. Set to true on books where the excerpt is a distinct subtitle
|
|
1542
|
+
* the author actually wants the reader to see at the top of every chapter. */
|
|
1543
|
+
showChapterExcerpt: boolean;
|
|
956
1544
|
content: string;
|
|
957
1545
|
toc: BookTocItem[];
|
|
958
1546
|
chapters: BookChapterEntry[];
|
|
@@ -967,8 +1555,11 @@ export interface BookChapterData {
|
|
|
967
1555
|
excerpt?: string;
|
|
968
1556
|
latex: boolean;
|
|
969
1557
|
commentable?: boolean;
|
|
970
|
-
|
|
1558
|
+
readingMinutes: number;
|
|
1559
|
+
wordCount: number;
|
|
971
1560
|
isFolder: boolean;
|
|
1561
|
+
/** Absolute path of the markdown source file. Used to resolve relative `.md` links. */
|
|
1562
|
+
sourcePath: string;
|
|
972
1563
|
prevChapter: { title: string; id: string } | null;
|
|
973
1564
|
nextChapter: { title: string; id: string } | null;
|
|
974
1565
|
}
|
|
@@ -978,15 +1569,25 @@ const BookChapterRefSchema = z.object({
|
|
|
978
1569
|
id: z.string(),
|
|
979
1570
|
});
|
|
980
1571
|
|
|
1572
|
+
// Recursive: a section can nest further sections or leaf chapter refs.
|
|
1573
|
+
const BookTocSectionSchema: z.ZodType<BookTocSection> = z.lazy(() =>
|
|
1574
|
+
z.object({
|
|
1575
|
+
section: z.string(),
|
|
1576
|
+
collapsible: z.boolean().optional(),
|
|
1577
|
+
items: z.array(z.union([BookTocSectionSchema, BookChapterRefSchema])),
|
|
1578
|
+
})
|
|
1579
|
+
);
|
|
1580
|
+
|
|
981
1581
|
const BookTocItemSchema: z.ZodType<BookTocItem> = z.union([
|
|
982
1582
|
z.object({
|
|
983
1583
|
part: z.string(),
|
|
984
1584
|
chapters: z.array(BookChapterRefSchema),
|
|
985
1585
|
}),
|
|
1586
|
+
BookTocSectionSchema,
|
|
986
1587
|
BookChapterRefSchema,
|
|
987
1588
|
]);
|
|
988
1589
|
|
|
989
|
-
const BookSchema = z.object({
|
|
1590
|
+
export const BookSchema = z.object({
|
|
990
1591
|
title: z.string(),
|
|
991
1592
|
excerpt: z.string().optional(),
|
|
992
1593
|
date: z.union([z.string(), z.date()]).transform(val => new Date(val).toISOString().split('T')[0]),
|
|
@@ -994,24 +1595,47 @@ const BookSchema = z.object({
|
|
|
994
1595
|
featured: z.boolean().optional().default(false),
|
|
995
1596
|
draft: z.boolean().optional().default(false),
|
|
996
1597
|
authors: z.array(z.string()).optional().default([]),
|
|
1598
|
+
latex: z.boolean().optional().default(false),
|
|
1599
|
+
showChapterExcerpt: z.boolean().optional().default(false),
|
|
997
1600
|
chapters: z.array(BookTocItemSchema),
|
|
998
1601
|
});
|
|
999
1602
|
|
|
1000
1603
|
const BookChapterSchema = z.object({
|
|
1001
|
-
title: z.string(),
|
|
1604
|
+
title: z.string().optional(),
|
|
1002
1605
|
excerpt: z.string().optional(),
|
|
1003
1606
|
draft: z.boolean().optional().default(false),
|
|
1004
1607
|
latex: z.boolean().optional().default(false),
|
|
1005
1608
|
commentable: z.boolean().optional(),
|
|
1006
1609
|
});
|
|
1007
1610
|
|
|
1008
|
-
function flattenBookChapters(toc: BookTocItem[]): BookChapterEntry[] {
|
|
1611
|
+
export function flattenBookChapters(toc: BookTocItem[]): BookChapterEntry[] {
|
|
1009
1612
|
const result: BookChapterEntry[] = [];
|
|
1613
|
+
|
|
1614
|
+
const walkSection = (
|
|
1615
|
+
items: Array<BookTocSection | BookChapterRef>,
|
|
1616
|
+
sectionPath: string[],
|
|
1617
|
+
): void => {
|
|
1618
|
+
for (const item of items) {
|
|
1619
|
+
if ('section' in item) {
|
|
1620
|
+
walkSection(item.items, [...sectionPath, item.section]);
|
|
1621
|
+
} else {
|
|
1622
|
+
result.push({
|
|
1623
|
+
title: item.title,
|
|
1624
|
+
id: item.id,
|
|
1625
|
+
section: sectionPath[sectionPath.length - 1],
|
|
1626
|
+
sectionPath: sectionPath.length > 0 ? [...sectionPath] : undefined,
|
|
1627
|
+
});
|
|
1628
|
+
}
|
|
1629
|
+
}
|
|
1630
|
+
};
|
|
1631
|
+
|
|
1010
1632
|
for (const item of toc) {
|
|
1011
1633
|
if ('part' in item) {
|
|
1012
1634
|
for (const ch of item.chapters) {
|
|
1013
1635
|
result.push({ title: ch.title, id: ch.id, part: item.part });
|
|
1014
1636
|
}
|
|
1637
|
+
} else if ('section' in item) {
|
|
1638
|
+
walkSection([item], []);
|
|
1015
1639
|
} else {
|
|
1016
1640
|
result.push({ title: item.title, id: item.id });
|
|
1017
1641
|
}
|
|
@@ -1019,6 +1643,35 @@ function flattenBookChapters(toc: BookTocItem[]): BookChapterEntry[] {
|
|
|
1019
1643
|
return result;
|
|
1020
1644
|
}
|
|
1021
1645
|
|
|
1646
|
+
/**
|
|
1647
|
+
* Resolves a chapter id (possibly nested with `/`) to a markdown file on disk.
|
|
1648
|
+
* Returns `{ path, isFolder }` if a file exists in one of the four supported forms,
|
|
1649
|
+
* or `null` if the id has no match. Guards against `..`-style path escapes — any
|
|
1650
|
+
* id that resolves outside `bookDir` returns null.
|
|
1651
|
+
*/
|
|
1652
|
+
function resolveChapterFilePath(
|
|
1653
|
+
bookDir: string,
|
|
1654
|
+
chapterId: string,
|
|
1655
|
+
): { path: string; isFolder: boolean } | null {
|
|
1656
|
+
const bookDirResolved = path.resolve(bookDir);
|
|
1657
|
+
const candidate = path.resolve(bookDir, chapterId);
|
|
1658
|
+
if (
|
|
1659
|
+
candidate !== bookDirResolved &&
|
|
1660
|
+
!candidate.startsWith(bookDirResolved + path.sep)
|
|
1661
|
+
) {
|
|
1662
|
+
return null;
|
|
1663
|
+
}
|
|
1664
|
+
const chMdx = `${candidate}.mdx`;
|
|
1665
|
+
const chMd = `${candidate}.md`;
|
|
1666
|
+
const chFolderMdx = path.join(candidate, 'index.mdx');
|
|
1667
|
+
const chFolderMd = path.join(candidate, 'index.md');
|
|
1668
|
+
if (fs.existsSync(chMdx)) return { path: chMdx, isFolder: false };
|
|
1669
|
+
if (fs.existsSync(chMd)) return { path: chMd, isFolder: false };
|
|
1670
|
+
if (fs.existsSync(chFolderMdx)) return { path: chFolderMdx, isFolder: true };
|
|
1671
|
+
if (fs.existsSync(chFolderMd)) return { path: chFolderMd, isFolder: true };
|
|
1672
|
+
return null;
|
|
1673
|
+
}
|
|
1674
|
+
|
|
1022
1675
|
export function getBookData(slug: string): BookData | null {
|
|
1023
1676
|
if (!fs.existsSync(booksDirectory)) return null;
|
|
1024
1677
|
const bookDir = path.join(booksDirectory, slug);
|
|
@@ -1041,17 +1694,22 @@ export function getBookData(slug: string): BookData | null {
|
|
|
1041
1694
|
}
|
|
1042
1695
|
const data = parsed.data;
|
|
1043
1696
|
|
|
1044
|
-
//
|
|
1697
|
+
// Resolve chapter file paths and surface missing files as build-time errors
|
|
1698
|
+
// (strict-build invariant: misconfiguration must fail loudly, not silently).
|
|
1045
1699
|
const chapters = flattenBookChapters(data.chapters);
|
|
1700
|
+
const missing: string[] = [];
|
|
1046
1701
|
for (const ch of chapters) {
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
const chFolderMdx = path.join(bookDir, ch.id, 'index.mdx');
|
|
1050
|
-
const chFolderMd = path.join(bookDir, ch.id, 'index.md');
|
|
1051
|
-
if (!fs.existsSync(chMdx) && !fs.existsSync(chMd) && !fs.existsSync(chFolderMdx) && !fs.existsSync(chFolderMd)) {
|
|
1052
|
-
console.warn(`Book "${slug}": chapter "${ch.id}" not found`);
|
|
1702
|
+
if (!resolveChapterFilePath(bookDir, ch.id)) {
|
|
1703
|
+
missing.push(ch.id);
|
|
1053
1704
|
}
|
|
1054
1705
|
}
|
|
1706
|
+
if (missing.length > 0) {
|
|
1707
|
+
throw new Error(
|
|
1708
|
+
`[amytis] Book "${slug}" references chapter${missing.length === 1 ? '' : 's'} ` +
|
|
1709
|
+
`with no matching file on disk: ${missing.map(id => `"${id}"`).join(', ')}. ` +
|
|
1710
|
+
`Expected one of <bookDir>/<id>.{md,mdx} or <bookDir>/<id>/index.{md,mdx}.`
|
|
1711
|
+
);
|
|
1712
|
+
}
|
|
1055
1713
|
|
|
1056
1714
|
let coverImage = data.coverImage;
|
|
1057
1715
|
if (coverImage && !coverImage.startsWith('http') && !coverImage.startsWith('/') && !coverImage.startsWith('text:')) {
|
|
@@ -1073,6 +1731,8 @@ export function getBookData(slug: string): BookData | null {
|
|
|
1073
1731
|
featured: data.featured,
|
|
1074
1732
|
draft: data.draft,
|
|
1075
1733
|
authors,
|
|
1734
|
+
latex: data.latex,
|
|
1735
|
+
showChapterExcerpt: data.showChapterExcerpt,
|
|
1076
1736
|
content: content.trim(),
|
|
1077
1737
|
toc: data.chapters,
|
|
1078
1738
|
chapters,
|
|
@@ -1084,17 +1744,9 @@ export function getBookChapter(bookSlug: string, chapterSlug: string): BookChapt
|
|
|
1084
1744
|
if (!book) return null;
|
|
1085
1745
|
|
|
1086
1746
|
const bookDir = path.join(booksDirectory, bookSlug);
|
|
1087
|
-
const
|
|
1088
|
-
|
|
1089
|
-
const
|
|
1090
|
-
const chFolderMd = path.join(bookDir, chapterSlug, 'index.md');
|
|
1091
|
-
let fullPath = '';
|
|
1092
|
-
let isFolder = false;
|
|
1093
|
-
if (fs.existsSync(chMdx)) fullPath = chMdx;
|
|
1094
|
-
else if (fs.existsSync(chMd)) fullPath = chMd;
|
|
1095
|
-
else if (fs.existsSync(chFolderMdx)) { fullPath = chFolderMdx; isFolder = true; }
|
|
1096
|
-
else if (fs.existsSync(chFolderMd)) { fullPath = chFolderMd; isFolder = true; }
|
|
1097
|
-
else return null;
|
|
1747
|
+
const resolved = resolveChapterFilePath(bookDir, chapterSlug);
|
|
1748
|
+
if (!resolved) return null;
|
|
1749
|
+
const { path: fullPath, isFolder } = resolved;
|
|
1098
1750
|
|
|
1099
1751
|
const fileContents = fs.readFileSync(fullPath, 'utf8');
|
|
1100
1752
|
const { data: rawData, content } = matter(fileContents);
|
|
@@ -1112,7 +1764,8 @@ export function getBookChapter(bookSlug: string, chapterSlug: string): BookChapt
|
|
|
1112
1764
|
|
|
1113
1765
|
const contentWithoutH1 = content.replace(/^\s*#\s+[^\n]+/, '').trim();
|
|
1114
1766
|
const headings = getHeadings(content);
|
|
1115
|
-
const
|
|
1767
|
+
const readingMinutes = calculateReadingMinutes(contentWithoutH1);
|
|
1768
|
+
const wordCount = calculateWordCount(contentWithoutH1);
|
|
1116
1769
|
const excerpt = data.excerpt || generateExcerpt(contentWithoutH1);
|
|
1117
1770
|
|
|
1118
1771
|
// Find prev/next
|
|
@@ -1120,22 +1773,40 @@ export function getBookChapter(bookSlug: string, chapterSlug: string): BookChapt
|
|
|
1120
1773
|
const prevChapter = chapterIndex > 0 ? book.chapters[chapterIndex - 1] : null;
|
|
1121
1774
|
const nextChapter = chapterIndex < book.chapters.length - 1 ? book.chapters[chapterIndex + 1] : null;
|
|
1122
1775
|
|
|
1776
|
+
// Title resolution: frontmatter wins, then book TOC entry, then first H1
|
|
1777
|
+
// in the body. VuePress chapters often omit frontmatter entirely and rely
|
|
1778
|
+
// on the H1 as the title, so this fallback chain keeps the import flow lossless.
|
|
1779
|
+
const fallbackFromToc = chapterIndex >= 0 ? book.chapters[chapterIndex].title : undefined;
|
|
1780
|
+
const h1Match = content.match(/^\s*#\s+([^\n]+)/);
|
|
1781
|
+
const fallbackFromH1 = h1Match?.[1].trim();
|
|
1782
|
+
const title = data.title || fallbackFromToc || fallbackFromH1 || chapterSlug;
|
|
1783
|
+
|
|
1123
1784
|
return {
|
|
1124
|
-
title
|
|
1785
|
+
title,
|
|
1125
1786
|
slug: chapterSlug,
|
|
1126
1787
|
bookSlug,
|
|
1127
1788
|
content: contentWithoutH1,
|
|
1128
1789
|
headings,
|
|
1129
1790
|
excerpt,
|
|
1130
|
-
latex:
|
|
1791
|
+
// Chapter-level `latex: true` takes precedence; otherwise inherit the
|
|
1792
|
+
// book-level flag so math-heavy books don't need per-chapter annotation.
|
|
1793
|
+
latex: data.latex || book.latex,
|
|
1131
1794
|
commentable: data.commentable,
|
|
1132
|
-
|
|
1795
|
+
readingMinutes,
|
|
1796
|
+
wordCount,
|
|
1133
1797
|
isFolder,
|
|
1798
|
+
sourcePath: fullPath,
|
|
1134
1799
|
prevChapter: prevChapter ? { title: prevChapter.title, id: prevChapter.id } : null,
|
|
1135
1800
|
nextChapter: nextChapter ? { title: nextChapter.title, id: nextChapter.id } : null,
|
|
1136
1801
|
};
|
|
1137
1802
|
}
|
|
1138
1803
|
|
|
1804
|
+
/** Absolute path of a book's content directory. Useful for plugins that
|
|
1805
|
+
* need to resolve relative paths from chapter source files. */
|
|
1806
|
+
export function getBookDirPath(bookSlug: string): string {
|
|
1807
|
+
return path.join(booksDirectory, bookSlug);
|
|
1808
|
+
}
|
|
1809
|
+
|
|
1139
1810
|
export function getAllBooks(): BookData[] {
|
|
1140
1811
|
if (!fs.existsSync(booksDirectory)) return [];
|
|
1141
1812
|
|
|
@@ -1353,7 +2024,8 @@ export interface NoteData {
|
|
|
1353
2024
|
content: string;
|
|
1354
2025
|
excerpt: string;
|
|
1355
2026
|
headings: Heading[];
|
|
1356
|
-
|
|
2027
|
+
readingMinutes: number;
|
|
2028
|
+
wordCount: number;
|
|
1357
2029
|
}
|
|
1358
2030
|
|
|
1359
2031
|
function parseNoteFile(fullPath: string, slug: string): NoteData {
|
|
@@ -1371,7 +2043,8 @@ function parseNoteFile(fullPath: string, slug: string): NoteData {
|
|
|
1371
2043
|
const date = data.date || fs.statSync(fullPath).mtime.toISOString().split('T')[0];
|
|
1372
2044
|
const excerpt = generateExcerpt(contentWithoutH1);
|
|
1373
2045
|
const headings = getHeadings(content);
|
|
1374
|
-
const
|
|
2046
|
+
const readingMinutes = calculateReadingMinutes(contentWithoutH1);
|
|
2047
|
+
const wordCount = calculateWordCount(contentWithoutH1);
|
|
1375
2048
|
|
|
1376
2049
|
return {
|
|
1377
2050
|
slug,
|
|
@@ -1386,7 +2059,8 @@ function parseNoteFile(fullPath: string, slug: string): NoteData {
|
|
|
1386
2059
|
content: contentWithoutH1,
|
|
1387
2060
|
excerpt,
|
|
1388
2061
|
headings,
|
|
1389
|
-
|
|
2062
|
+
readingMinutes,
|
|
2063
|
+
wordCount,
|
|
1390
2064
|
};
|
|
1391
2065
|
}
|
|
1392
2066
|
|