@hutusi/amytis 1.14.0 → 1.15.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 +16 -0
- package/README.md +33 -1
- package/README.zh.md +33 -1
- package/TODO.md +10 -0
- package/bun.lock +69 -41
- 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/ARCHITECTURE.md +22 -3
- package/docs/CONTRIBUTING.md +11 -0
- package/eslint.config.mjs +2 -0
- package/next.config.ts +2 -2
- package/package.json +22 -16
- 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-knowledge-graph.ts +2 -1
- package/scripts/render-rst.py +719 -0
- package/scripts/run-with-rst-python.ts +42 -0
- package/src/app/[slug]/[postSlug]/page.tsx +20 -10
- package/src/app/[slug]/page/[page]/page.tsx +15 -0
- package/src/app/globals.css +165 -0
- 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/components/AuthorCard.tsx +25 -16
- package/src/components/CoverImage.tsx +5 -2
- package/src/components/MarkdownRenderer.test.tsx +16 -0
- package/src/components/MarkdownRenderer.tsx +4 -1
- package/src/components/RstRenderer.test.tsx +93 -0
- package/src/components/RstRenderer.tsx +122 -0
- package/src/layouts/PostLayout.tsx +5 -1
- package/src/layouts/SimpleLayout.tsx +10 -3
- package/src/lib/image-utils.test.ts +19 -0
- package/src/lib/image-utils.ts +11 -0
- package/src/lib/markdown.test.ts +140 -2
- package/src/lib/markdown.ts +731 -210
- package/src/lib/rehype-image-metadata.ts +2 -2
- package/src/lib/rst-renderer.test.ts +355 -0
- package/src/lib/rst-renderer.ts +617 -0
- package/src/lib/rst.test.ts +140 -0
- package/src/lib/rst.ts +470 -0
- package/src/lib/series-redirects.ts +42 -0
- package/tests/integration/feed-utils.test.ts +13 -0
- package/tests/integration/reading-time-headings.test.ts +5 -9
- package/tests/integration/series-draft.test.ts +16 -2
- package/tests/integration/series.test.ts +93 -0
- package/tests/tooling/build-pagefind.test.ts +66 -0
- package/tests/unit/static-params.test.ts +140 -0
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(),
|
|
@@ -119,10 +125,142 @@ export interface PostData {
|
|
|
119
125
|
redirectFrom?: string[];
|
|
120
126
|
readingTime: string;
|
|
121
127
|
content: string;
|
|
128
|
+
renderedHtml?: string;
|
|
129
|
+
plainText?: string;
|
|
122
130
|
headings: Heading[];
|
|
123
131
|
contentLocales?: Record<string, { content: string; title?: string; excerpt?: string; headings?: Heading[] }>;
|
|
124
132
|
/** Public-relative base path used for resolving co-located images (e.g. "posts/my-post" or "posts" for root flat files). */
|
|
125
133
|
imageBaseSlug: string;
|
|
134
|
+
sourceFormat?: 'markdown' | 'rst';
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
type SeriesFormat = 'markdown' | 'rst';
|
|
138
|
+
|
|
139
|
+
interface SeriesIndexInfo {
|
|
140
|
+
format: SeriesFormat;
|
|
141
|
+
fullPath: string;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
interface SeriesContentEntry {
|
|
145
|
+
fullPath: string;
|
|
146
|
+
slug: string;
|
|
147
|
+
dateFromFileName?: string;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
interface PendingRstPostEntry {
|
|
151
|
+
fullPath: string;
|
|
152
|
+
slug: string;
|
|
153
|
+
dateFromFileName?: string;
|
|
154
|
+
seriesSlug?: string;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function getCacheEnvKey(): string {
|
|
158
|
+
return process.env.NODE_ENV === 'production' ? 'production' : 'development';
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const postsCache = new Map<string, PostData[]>();
|
|
162
|
+
const pagesCache = new Map<string, PostData[]>();
|
|
163
|
+
const tagsCache = new Map<string, Record<string, number>>();
|
|
164
|
+
const authorsCache = new Map<string, Record<string, number>>();
|
|
165
|
+
const featuredPostsCache = new Map<string, PostData[]>();
|
|
166
|
+
const adjacentPostsCache = new Map<string, Map<string, { prev: PostData | null; next: PostData | null }>>();
|
|
167
|
+
const relatedPostsCache = new Map<string, Map<string, PostData[]>>();
|
|
168
|
+
const seriesDataCache = new Map<string, Map<string, PostData | null>>();
|
|
169
|
+
const seriesPostsCache = new Map<string, Map<string, PostData[]>>();
|
|
170
|
+
const allSeriesCache = new Map<string, Record<string, PostData[]>>();
|
|
171
|
+
const featuredSeriesCache = new Map<string, Record<string, PostData[]>>();
|
|
172
|
+
const seriesLatestDateCache = new Map<string, Map<string, string>>();
|
|
173
|
+
const collectionPostsCache = new Map<string, Map<string, PostData[]>>();
|
|
174
|
+
const collectionsForPostCache = new Map<string, Map<string, CollectionContext[]>>();
|
|
175
|
+
const seriesAuthorsCache = new Map<string, Map<string, string[] | null>>();
|
|
176
|
+
const seriesTitleCache = new Map<string, Map<string, string | undefined>>();
|
|
177
|
+
let pythonRstRendererAvailable: boolean | null = null;
|
|
178
|
+
|
|
179
|
+
const PYTHON_RUNTIME_UNAVAILABLE_PATTERN = /docutils|No module named|python(?:3)? .*not found|interpreter not found|ENOENT.*python/i;
|
|
180
|
+
|
|
181
|
+
function isPythonRuntimeUnavailable(error: unknown): boolean {
|
|
182
|
+
if (!(error instanceof Error)) return false;
|
|
183
|
+
if (error.message.includes('__RST_FALLBACK__')) return true;
|
|
184
|
+
if (error.message.includes('rST file not found')) return false;
|
|
185
|
+
return PYTHON_RUNTIME_UNAVAILABLE_PATTERN.test(error.message);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function getRstImageBaseSlug(fullPath: string, slug: string): string {
|
|
189
|
+
const isRootFlatPost = path.basename(fullPath) !== 'index.rst' &&
|
|
190
|
+
path.dirname(fullPath) === contentDirectory;
|
|
191
|
+
return isRootFlatPost ? 'posts' : `posts/${slug}`;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function isSeriesIndexRst(fullPath: string, slug: string, seriesName?: string): boolean {
|
|
195
|
+
return Boolean(
|
|
196
|
+
seriesName &&
|
|
197
|
+
slug === seriesName &&
|
|
198
|
+
(path.basename(fullPath) === 'index.rst' || path.basename(fullPath) === 'README.rst')
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function slugFromRstToctreeTarget(target: string): string | null {
|
|
203
|
+
const trimmed = target.trim();
|
|
204
|
+
if (!trimmed || trimmed.startsWith(':')) return null;
|
|
205
|
+
if (/^[a-z]+:\/\//i.test(trimmed) || trimmed.startsWith('/')) return null;
|
|
206
|
+
|
|
207
|
+
const withoutAnchor = trimmed.split('#')[0]?.split('?')[0]?.trim();
|
|
208
|
+
if (!withoutAnchor) return null;
|
|
209
|
+
|
|
210
|
+
const normalized = withoutAnchor.replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/+$/, '');
|
|
211
|
+
if (!normalized || normalized.startsWith('../')) return null;
|
|
212
|
+
|
|
213
|
+
const withoutExt = normalized.replace(/\.rst$/i, '');
|
|
214
|
+
const parts = withoutExt.split('/').filter(Boolean);
|
|
215
|
+
if (parts.length === 0) return null;
|
|
216
|
+
|
|
217
|
+
const last = parts[parts.length - 1];
|
|
218
|
+
if (last === 'index' || last === 'README') {
|
|
219
|
+
return parts.length > 1 ? parts[parts.length - 2] : null;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return last;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function extractRstToctreePosts(source: string): string[] {
|
|
226
|
+
const lines = source.replace(/\r\n?/g, '\n').split('\n');
|
|
227
|
+
const posts: string[] = [];
|
|
228
|
+
const seen = new Set<string>();
|
|
229
|
+
|
|
230
|
+
for (let i = 0; i < lines.length; i++) {
|
|
231
|
+
if (!/^\s*\.\.\s+toctree::\s*$/.test(lines[i])) continue;
|
|
232
|
+
|
|
233
|
+
i++;
|
|
234
|
+
while (i < lines.length) {
|
|
235
|
+
const line = lines[i];
|
|
236
|
+
if (!line.trim()) {
|
|
237
|
+
i++;
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
if (!/^\s+/.test(line)) {
|
|
241
|
+
i--;
|
|
242
|
+
break;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const trimmed = line.trim();
|
|
246
|
+
if (!trimmed.startsWith(':')) {
|
|
247
|
+
const slug = slugFromRstToctreeTarget(trimmed);
|
|
248
|
+
if (slug && !seen.has(slug)) {
|
|
249
|
+
seen.add(slug);
|
|
250
|
+
posts.push(slug);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
i++;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return posts;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function shouldUsePythonRstRenderer(): boolean {
|
|
261
|
+
if (process.env.AMYTIS_ENABLE_PYTHON_RST === '1') return true;
|
|
262
|
+
if (process.env.AMYTIS_ENABLE_PYTHON_RST === '0') return false;
|
|
263
|
+
return process.env.NODE_ENV !== 'test';
|
|
126
264
|
}
|
|
127
265
|
|
|
128
266
|
export function calculateReadingTime(content: string): string {
|
|
@@ -183,22 +321,47 @@ export function getHeadings(content: string): Heading[] {
|
|
|
183
321
|
* Returns null if no authors are configured (as opposed to the default fallback).
|
|
184
322
|
*/
|
|
185
323
|
export function getSeriesAuthors(seriesSlug: string): string[] | null {
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
324
|
+
const cacheKey = getCacheEnvKey();
|
|
325
|
+
let bySlug = seriesAuthorsCache.get(cacheKey);
|
|
326
|
+
if (!bySlug) {
|
|
327
|
+
bySlug = new Map();
|
|
328
|
+
seriesAuthorsCache.set(cacheKey, bySlug);
|
|
329
|
+
}
|
|
330
|
+
if (bySlug.has(seriesSlug)) return bySlug.get(seriesSlug) ?? null;
|
|
189
331
|
|
|
190
|
-
|
|
191
|
-
if (
|
|
192
|
-
|
|
193
|
-
|
|
332
|
+
const indexInfo = resolveSeriesIndexInfo(seriesSlug);
|
|
333
|
+
if (!indexInfo) {
|
|
334
|
+
bySlug.set(seriesSlug, null);
|
|
335
|
+
return null;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
if (indexInfo.format === 'rst') {
|
|
339
|
+
const parsed = parseRstDocument(readUtf8File(indexInfo.fullPath));
|
|
340
|
+
if (parsed.metadata.authors && parsed.metadata.authors.length > 0) {
|
|
341
|
+
bySlug.set(seriesSlug, parsed.metadata.authors);
|
|
342
|
+
return parsed.metadata.authors;
|
|
343
|
+
}
|
|
344
|
+
if (parsed.metadata.author && typeof parsed.metadata.author === 'string') {
|
|
345
|
+
const authors = [parsed.metadata.author];
|
|
346
|
+
bySlug.set(seriesSlug, authors);
|
|
347
|
+
return authors;
|
|
348
|
+
}
|
|
349
|
+
bySlug.set(seriesSlug, null);
|
|
350
|
+
return null;
|
|
351
|
+
}
|
|
194
352
|
|
|
195
|
-
const { data } = matter(
|
|
353
|
+
const { data } = matter(readUtf8File(indexInfo.fullPath));
|
|
196
354
|
if (data.authors && Array.isArray(data.authors) && data.authors.length > 0) {
|
|
197
|
-
|
|
355
|
+
const authors = data.authors as string[];
|
|
356
|
+
bySlug.set(seriesSlug, authors);
|
|
357
|
+
return authors;
|
|
198
358
|
}
|
|
199
359
|
if (data.author && typeof data.author === 'string') {
|
|
200
|
-
|
|
360
|
+
const authors = [data.author as string];
|
|
361
|
+
bySlug.set(seriesSlug, authors);
|
|
362
|
+
return authors;
|
|
201
363
|
}
|
|
364
|
+
bySlug.set(seriesSlug, null);
|
|
202
365
|
return null;
|
|
203
366
|
}
|
|
204
367
|
|
|
@@ -221,17 +384,186 @@ export function resolveSeriesAuthors(slug: string, posts: PostData[]): string[]
|
|
|
221
384
|
.map(([name]) => name);
|
|
222
385
|
}
|
|
223
386
|
|
|
387
|
+
function parseSlugAndDate(rawName: string): { slug: string; dateFromFileName?: string } {
|
|
388
|
+
const dateRegex = /^(\d{4}-\d{2}-\d{2})-(.*)$/;
|
|
389
|
+
const match = rawName.match(dateRegex);
|
|
390
|
+
|
|
391
|
+
if (match) {
|
|
392
|
+
return {
|
|
393
|
+
dateFromFileName: match[1],
|
|
394
|
+
slug: siteConfig.posts?.includeDateInUrl ? rawName : match[2],
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
return { slug: rawName };
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function isMarkdownFilename(name: string): boolean {
|
|
402
|
+
return name.endsWith('.md') || name.endsWith('.mdx');
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function isRstFilename(name: string): boolean {
|
|
406
|
+
return name.endsWith('.rst');
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function assertSafeSeriesSlug(seriesSlug: string): void {
|
|
410
|
+
if (!seriesSlug || path.isAbsolute(seriesSlug)) {
|
|
411
|
+
throw new Error(`[amytis] Invalid series slug "${seriesSlug}".`);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const segments = seriesSlug.split(/[\\/]/);
|
|
415
|
+
if (segments.length !== 1 || segments[0] === '.' || segments[0] === '..') {
|
|
416
|
+
throw new Error(`[amytis] Invalid series slug "${seriesSlug}".`);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function resolveUniqueSeriesIndex(seriesSlug: string, format: SeriesFormat): string | null {
|
|
421
|
+
assertSafeSeriesSlug(seriesSlug);
|
|
422
|
+
const seriesPath = path.join(seriesDirectory, seriesSlug);
|
|
423
|
+
const candidates = format === 'rst'
|
|
424
|
+
? ['index.rst', 'README.rst']
|
|
425
|
+
: ['index.mdx', 'index.md', 'README.mdx', 'README.md'];
|
|
426
|
+
|
|
427
|
+
const matches = candidates
|
|
428
|
+
.map(name => path.join(seriesPath, name))
|
|
429
|
+
.filter(fullPath => fs.existsSync(fullPath));
|
|
430
|
+
|
|
431
|
+
if (matches.length > 1) {
|
|
432
|
+
throw new Error(
|
|
433
|
+
`[amytis] Series "${seriesSlug}" has multiple ${format} index files: ${matches.map(match => path.basename(match)).join(', ')}.`
|
|
434
|
+
);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
return matches[0] ?? null;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function resolveSeriesIndexInfo(slug: string): SeriesIndexInfo | null {
|
|
441
|
+
assertSafeSeriesSlug(slug);
|
|
442
|
+
if (!fs.existsSync(seriesDirectory)) return null;
|
|
443
|
+
const seriesPath = path.join(seriesDirectory, slug);
|
|
444
|
+
if (!fs.existsSync(seriesPath) || !fs.statSync(seriesPath).isDirectory()) return null;
|
|
445
|
+
|
|
446
|
+
const rstIndex = resolveUniqueSeriesIndex(slug, 'rst');
|
|
447
|
+
const markdownIndex = resolveUniqueSeriesIndex(slug, 'markdown');
|
|
448
|
+
|
|
449
|
+
if (rstIndex && markdownIndex) {
|
|
450
|
+
throw new Error(
|
|
451
|
+
`[amytis] Series "${slug}" cannot contain both rST and Markdown index files (${path.basename(rstIndex)} and ${path.basename(markdownIndex)}).`
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
if (rstIndex) return { format: 'rst', fullPath: rstIndex };
|
|
455
|
+
if (markdownIndex) return { format: 'markdown', fullPath: markdownIndex };
|
|
456
|
+
return null;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function getSeriesContentEntries(seriesSlug: string): SeriesContentEntry[] {
|
|
460
|
+
const indexInfo = resolveSeriesIndexInfo(seriesSlug);
|
|
461
|
+
if (!indexInfo) return [];
|
|
462
|
+
|
|
463
|
+
const seriesPath = path.join(seriesDirectory, seriesSlug);
|
|
464
|
+
const seriesItems = fs.readdirSync(seriesPath, { withFileTypes: true });
|
|
465
|
+
const entries: SeriesContentEntry[] = [];
|
|
466
|
+
const seenSlugs = new Map<string, string>();
|
|
467
|
+
const seriesIndexBasenames = new Set(['index.rst', 'README.rst', 'index.md', 'index.mdx', 'README.md', 'README.mdx']);
|
|
468
|
+
|
|
469
|
+
for (const item of seriesItems) {
|
|
470
|
+
if (seriesIndexBasenames.has(item.name)) continue;
|
|
471
|
+
|
|
472
|
+
if (item.isFile()) {
|
|
473
|
+
const isMarkdown = isMarkdownFilename(item.name);
|
|
474
|
+
const isRst = isRstFilename(item.name);
|
|
475
|
+
if (!isMarkdown && !isRst) continue;
|
|
476
|
+
|
|
477
|
+
const itemFormat: SeriesFormat = isRst ? 'rst' : 'markdown';
|
|
478
|
+
if (itemFormat !== indexInfo.format) {
|
|
479
|
+
throw new Error(`[amytis] Series "${seriesSlug}" mixes ${indexInfo.format} and ${itemFormat} files. Offending file: ${item.name}`);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
const rawName = item.name.replace(/\.(mdx?|rst)$/, '');
|
|
483
|
+
const { slug, dateFromFileName } = parseSlugAndDate(rawName);
|
|
484
|
+
const prior = seenSlugs.get(slug);
|
|
485
|
+
if (prior) {
|
|
486
|
+
throw new Error(`[amytis] Series "${seriesSlug}" contains duplicate post slug "${slug}" from "${prior}" and "${item.name}".`);
|
|
487
|
+
}
|
|
488
|
+
seenSlugs.set(slug, item.name);
|
|
489
|
+
entries.push({ fullPath: path.join(seriesPath, item.name), slug, dateFromFileName });
|
|
490
|
+
continue;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
if (item.isDirectory()) {
|
|
494
|
+
const folderPath = path.join(seriesPath, item.name);
|
|
495
|
+
const folderIndexRst = path.join(folderPath, 'index.rst');
|
|
496
|
+
const folderIndexMdx = path.join(folderPath, 'index.mdx');
|
|
497
|
+
const folderIndexMd = path.join(folderPath, 'index.md');
|
|
498
|
+
const hasRst = fs.existsSync(folderIndexRst);
|
|
499
|
+
const hasMdx = fs.existsSync(folderIndexMdx);
|
|
500
|
+
const hasMd = fs.existsSync(folderIndexMd);
|
|
501
|
+
const markdownCount = Number(hasMdx) + Number(hasMd);
|
|
502
|
+
const totalIndexCount = Number(hasRst) + markdownCount;
|
|
503
|
+
|
|
504
|
+
if (totalIndexCount === 0) continue;
|
|
505
|
+
if (hasRst && markdownCount > 0) {
|
|
506
|
+
throw new Error(`[amytis] Series "${seriesSlug}" post folder "${item.name}" cannot contain both index.rst and Markdown index files.`);
|
|
507
|
+
}
|
|
508
|
+
if (markdownCount > 1) {
|
|
509
|
+
throw new Error(`[amytis] Series "${seriesSlug}" post folder "${item.name}" cannot contain both index.md and index.mdx.`);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
const itemFormat: SeriesFormat = hasRst ? 'rst' : 'markdown';
|
|
513
|
+
if (itemFormat !== indexInfo.format) {
|
|
514
|
+
throw new Error(`[amytis] Series "${seriesSlug}" mixes ${indexInfo.format} and ${itemFormat} files. Offending folder: ${item.name}`);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
const { slug, dateFromFileName } = parseSlugAndDate(item.name);
|
|
518
|
+
const prior = seenSlugs.get(slug);
|
|
519
|
+
if (prior) {
|
|
520
|
+
throw new Error(`[amytis] Series "${seriesSlug}" contains duplicate post slug "${slug}" from "${prior}" and "${item.name}".`);
|
|
521
|
+
}
|
|
522
|
+
seenSlugs.set(slug, item.name);
|
|
523
|
+
entries.push({
|
|
524
|
+
fullPath: hasRst ? folderIndexRst : (hasMdx ? folderIndexMdx : folderIndexMd),
|
|
525
|
+
slug,
|
|
526
|
+
dateFromFileName,
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
return entries;
|
|
532
|
+
}
|
|
533
|
+
|
|
224
534
|
function getSeriesTitle(slug: string): string | undefined {
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
535
|
+
const cacheKey = getCacheEnvKey();
|
|
536
|
+
let bySlug = seriesTitleCache.get(cacheKey);
|
|
537
|
+
if (!bySlug) {
|
|
538
|
+
bySlug = new Map();
|
|
539
|
+
seriesTitleCache.set(cacheKey, bySlug);
|
|
540
|
+
}
|
|
541
|
+
if (bySlug.has(slug)) return bySlug.get(slug);
|
|
542
|
+
|
|
543
|
+
const indexInfo = resolveSeriesIndexInfo(slug);
|
|
544
|
+
if (!indexInfo) {
|
|
545
|
+
bySlug.set(slug, undefined);
|
|
546
|
+
return undefined;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
if (indexInfo.format === 'rst') {
|
|
550
|
+
const parsed = parseRstDocument(readUtf8File(indexInfo.fullPath));
|
|
551
|
+
if (parsed.metadata.draft === true) {
|
|
552
|
+
bySlug.set(slug, undefined);
|
|
553
|
+
return undefined;
|
|
554
|
+
}
|
|
555
|
+
bySlug.set(slug, parsed.title);
|
|
556
|
+
return parsed.title;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
const { data } = matter(readUtf8File(indexInfo.fullPath));
|
|
560
|
+
if (data.draft === true) {
|
|
561
|
+
bySlug.set(slug, undefined);
|
|
562
|
+
return undefined;
|
|
563
|
+
}
|
|
564
|
+
const title = typeof data.title === 'string' ? data.title : undefined;
|
|
565
|
+
bySlug.set(slug, title);
|
|
566
|
+
return title;
|
|
235
567
|
}
|
|
236
568
|
|
|
237
569
|
function parseMarkdownFile(fullPath: string, slug: string, dateFromFileName?: string, seriesName?: string): PostData {
|
|
@@ -280,7 +612,7 @@ function parseMarkdownFile(fullPath: string, slug: string, dateFromFileName?: st
|
|
|
280
612
|
|
|
281
613
|
let date = data.date;
|
|
282
614
|
if (!date && dateFromFileName) date = dateFromFileName;
|
|
283
|
-
if (!date) date =
|
|
615
|
+
if (!date) date = fs.statSync(fullPath).mtime.toISOString().split('T')[0];
|
|
284
616
|
|
|
285
617
|
const headings = getHeadings(content);
|
|
286
618
|
|
|
@@ -319,11 +651,179 @@ function parseMarkdownFile(fullPath: string, slug: string, dateFromFileName?: st
|
|
|
319
651
|
content: contentWithoutH1,
|
|
320
652
|
headings,
|
|
321
653
|
imageBaseSlug,
|
|
654
|
+
sourceFormat: 'markdown',
|
|
322
655
|
};
|
|
323
656
|
}
|
|
324
657
|
|
|
658
|
+
export function parseMarkdownFileForTests(
|
|
659
|
+
fullPath: string,
|
|
660
|
+
slug: string,
|
|
661
|
+
dateFromFileName?: string,
|
|
662
|
+
seriesName?: string,
|
|
663
|
+
): PostData {
|
|
664
|
+
return parseMarkdownFile(fullPath, slug, dateFromFileName, seriesName);
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
function parseRstFile(
|
|
668
|
+
fullPath: string,
|
|
669
|
+
slug: string,
|
|
670
|
+
dateFromFileName?: string,
|
|
671
|
+
seriesName?: string,
|
|
672
|
+
preRendered?: RenderedRstDocument,
|
|
673
|
+
): PostData {
|
|
674
|
+
try {
|
|
675
|
+
const imageBaseSlug = getRstImageBaseSlug(fullPath, slug);
|
|
676
|
+
const fileContents = fs.readFileSync(fullPath, 'utf8');
|
|
677
|
+
|
|
678
|
+
let parsedTitle: string;
|
|
679
|
+
let parsedBody: string;
|
|
680
|
+
let parsedText: string | undefined;
|
|
681
|
+
let parsedHeadings: Heading[];
|
|
682
|
+
let parsedExcerpt: string;
|
|
683
|
+
let parsedReadingTime: string;
|
|
684
|
+
let parsedHtml: string | undefined;
|
|
685
|
+
let data: ReturnType<typeof parseRstDocument>['metadata'];
|
|
686
|
+
try {
|
|
687
|
+
if (preRendered) {
|
|
688
|
+
const rendered = preRendered;
|
|
689
|
+
parsedTitle = rendered.title;
|
|
690
|
+
parsedBody = rendered.text;
|
|
691
|
+
parsedText = rendered.text;
|
|
692
|
+
parsedHeadings = rendered.headings;
|
|
693
|
+
parsedExcerpt = rendered.excerpt;
|
|
694
|
+
parsedReadingTime = rendered.readingTime;
|
|
695
|
+
parsedHtml = rendered.html;
|
|
696
|
+
data = rendered.metadata;
|
|
697
|
+
} else if (shouldUsePythonRstRenderer() && pythonRstRendererAvailable !== false) {
|
|
698
|
+
const rendered = renderRstFile(fullPath, imageBaseSlug);
|
|
699
|
+
pythonRstRendererAvailable = true;
|
|
700
|
+
parsedTitle = rendered.title;
|
|
701
|
+
parsedBody = rendered.text;
|
|
702
|
+
parsedText = rendered.text;
|
|
703
|
+
parsedHeadings = rendered.headings;
|
|
704
|
+
parsedExcerpt = rendered.excerpt;
|
|
705
|
+
parsedReadingTime = rendered.readingTime;
|
|
706
|
+
parsedHtml = rendered.html;
|
|
707
|
+
data = rendered.metadata;
|
|
708
|
+
} else {
|
|
709
|
+
throw new Error('__RST_FALLBACK__');
|
|
710
|
+
}
|
|
711
|
+
} catch (error) {
|
|
712
|
+
if (!isPythonRuntimeUnavailable(error)) {
|
|
713
|
+
throw error;
|
|
714
|
+
}
|
|
715
|
+
if (pythonRstRendererAvailable !== false) {
|
|
716
|
+
pythonRstRendererAvailable = false;
|
|
717
|
+
}
|
|
718
|
+
const parsed = parseRstDocument(fileContents);
|
|
719
|
+
parsedTitle = parsed.title;
|
|
720
|
+
parsedBody = parsed.body;
|
|
721
|
+
parsedHeadings = parsed.headings;
|
|
722
|
+
parsedExcerpt = parsed.excerpt;
|
|
723
|
+
parsedReadingTime = parsed.readingTime;
|
|
724
|
+
data = parsed.metadata;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
const effectiveSeriesSlug = data.series || seriesName;
|
|
728
|
+
let authors: string[] = [];
|
|
729
|
+
if (data.authors && data.authors.length > 0) {
|
|
730
|
+
authors = data.authors;
|
|
731
|
+
} else if (data.author) {
|
|
732
|
+
authors = [data.author];
|
|
733
|
+
} else {
|
|
734
|
+
if (effectiveSeriesSlug) {
|
|
735
|
+
const seriesAuthors = getSeriesAuthors(effectiveSeriesSlug);
|
|
736
|
+
if (seriesAuthors) authors = seriesAuthors;
|
|
737
|
+
}
|
|
738
|
+
if (authors.length === 0) {
|
|
739
|
+
const defaultAuthors = siteConfig.posts?.authors?.default;
|
|
740
|
+
if (defaultAuthors && defaultAuthors.length > 0) {
|
|
741
|
+
authors = defaultAuthors;
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
let date = data.date;
|
|
747
|
+
if (!date && dateFromFileName) date = dateFromFileName;
|
|
748
|
+
if (!date) date = fs.statSync(fullPath).mtime.toISOString().split('T')[0];
|
|
749
|
+
|
|
750
|
+
let coverImage = data.coverImage;
|
|
751
|
+
if (coverImage && !coverImage.startsWith('http') && !coverImage.startsWith('/') && !coverImage.startsWith('text:')) {
|
|
752
|
+
const cleanPath = coverImage.replace(/^\.\//, '');
|
|
753
|
+
coverImage = `/${imageBaseSlug}/${cleanPath}`;
|
|
754
|
+
}
|
|
755
|
+
const toctreePosts = isSeriesIndexRst(fullPath, slug, seriesName)
|
|
756
|
+
? extractRstToctreePosts(fileContents)
|
|
757
|
+
: [];
|
|
758
|
+
const seriesPosts = data.posts && data.posts.length > 0
|
|
759
|
+
? data.posts
|
|
760
|
+
: ((data.sort === undefined || data.sort === 'manual') && toctreePosts.length > 0 ? toctreePosts : undefined);
|
|
761
|
+
const sort = data.sort ?? (seriesPosts ? 'manual' : 'date-desc');
|
|
762
|
+
|
|
763
|
+
return {
|
|
764
|
+
slug,
|
|
765
|
+
title: parsedTitle,
|
|
766
|
+
subtitle: data.subtitle,
|
|
767
|
+
date,
|
|
768
|
+
excerpt: data.excerpt || parsedExcerpt,
|
|
769
|
+
category: data.category ?? 'Uncategorized',
|
|
770
|
+
tags: data.tags ?? [],
|
|
771
|
+
authors,
|
|
772
|
+
layout: data.layout ?? 'post',
|
|
773
|
+
series: effectiveSeriesSlug,
|
|
774
|
+
seriesTitle: effectiveSeriesSlug ? getSeriesTitle(effectiveSeriesSlug) : undefined,
|
|
775
|
+
coverImage,
|
|
776
|
+
sort,
|
|
777
|
+
posts: seriesPosts,
|
|
778
|
+
type: data.type,
|
|
779
|
+
featured: data.featured ?? false,
|
|
780
|
+
pinned: data.pinned ?? false,
|
|
781
|
+
draft: data.draft ?? false,
|
|
782
|
+
latex: data.latex ?? false,
|
|
783
|
+
toc: data.toc ?? true,
|
|
784
|
+
commentable: data.commentable,
|
|
785
|
+
redirectFrom: data.redirectFrom ?? [],
|
|
786
|
+
readingTime: parsedReadingTime,
|
|
787
|
+
content: parsedBody,
|
|
788
|
+
renderedHtml: parsedHtml,
|
|
789
|
+
plainText: parsedText,
|
|
790
|
+
headings: parsedHeadings,
|
|
791
|
+
imageBaseSlug,
|
|
792
|
+
sourceFormat: 'rst',
|
|
793
|
+
};
|
|
794
|
+
} catch (error) {
|
|
795
|
+
if (error instanceof RstParseError) {
|
|
796
|
+
throw new RstParseError(`${error.message} (${fullPath})`);
|
|
797
|
+
}
|
|
798
|
+
throw error;
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
export function parseRstFileForTests(
|
|
803
|
+
fullPath: string,
|
|
804
|
+
slug: string,
|
|
805
|
+
dateFromFileName?: string,
|
|
806
|
+
seriesName?: string,
|
|
807
|
+
preRendered?: RenderedRstDocument,
|
|
808
|
+
): PostData {
|
|
809
|
+
return parseRstFile(fullPath, slug, dateFromFileName, seriesName, preRendered);
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
export function resetPythonRstRendererAvailabilityForTests(value: boolean | null = null): void {
|
|
813
|
+
pythonRstRendererAvailable = value;
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
export function getPythonRstRendererAvailabilityForTests(): boolean | null {
|
|
817
|
+
return pythonRstRendererAvailable;
|
|
818
|
+
}
|
|
819
|
+
|
|
325
820
|
export function getAllPosts(): PostData[] {
|
|
821
|
+
const cacheKey = getCacheEnvKey();
|
|
822
|
+
const cached = postsCache.get(cacheKey);
|
|
823
|
+
if (cached) return cached;
|
|
824
|
+
|
|
326
825
|
const allPostsData: PostData[] = [];
|
|
826
|
+
const pendingRstPosts: PendingRstPostEntry[] = [];
|
|
327
827
|
|
|
328
828
|
// Helper to process a directory
|
|
329
829
|
const processDirectory = (dir: string, isSeriesDir: boolean = false) => {
|
|
@@ -336,81 +836,29 @@ export function getAllPosts(): PostData[] {
|
|
|
336
836
|
let slug = '';
|
|
337
837
|
let dateFromFileName = undefined;
|
|
338
838
|
|
|
339
|
-
const dateRegex = /^(\d{4}-\d{2}-\d{2})-(.*)$/;
|
|
340
839
|
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
|
-
}
|
|
840
|
+
({ slug, dateFromFileName } = parseSlugAndDate(rawName));
|
|
353
841
|
|
|
354
842
|
// Handle Series Directory logic
|
|
355
843
|
if (isSeriesDir) {
|
|
356
844
|
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
|
|
845
|
+
const seriesSlug = item.name;
|
|
846
|
+
const indexInfo = resolveSeriesIndexInfo(seriesSlug);
|
|
847
|
+
if (!indexInfo) return;
|
|
848
|
+
|
|
849
|
+
getSeriesContentEntries(seriesSlug).forEach(entry => {
|
|
850
|
+
if (indexInfo.format === 'rst') {
|
|
851
|
+
pendingRstPosts.push({
|
|
852
|
+
fullPath: entry.fullPath,
|
|
853
|
+
slug: entry.slug,
|
|
854
|
+
dateFromFileName: entry.dateFromFileName,
|
|
855
|
+
seriesSlug,
|
|
856
|
+
});
|
|
857
|
+
} else {
|
|
858
|
+
allPostsData.push(parseMarkdownFile(entry.fullPath, entry.slug, entry.dateFromFileName, seriesSlug));
|
|
859
|
+
}
|
|
860
|
+
});
|
|
861
|
+
return;
|
|
414
862
|
}
|
|
415
863
|
}
|
|
416
864
|
|
|
@@ -434,7 +882,41 @@ export function getAllPosts(): PostData[] {
|
|
|
434
882
|
processDirectory(contentDirectory);
|
|
435
883
|
processDirectory(seriesDirectory, true);
|
|
436
884
|
|
|
437
|
-
|
|
885
|
+
if (pendingRstPosts.length > 0) {
|
|
886
|
+
let batchRenderedByFile: Map<string, RenderedRstDocument> | null = null;
|
|
887
|
+
|
|
888
|
+
if (shouldUsePythonRstRenderer() && pythonRstRendererAvailable !== false) {
|
|
889
|
+
try {
|
|
890
|
+
batchRenderedByFile = renderRstFilesBatch(
|
|
891
|
+
pendingRstPosts.map(entry => ({
|
|
892
|
+
file: entry.fullPath,
|
|
893
|
+
imageBaseSlug: getRstImageBaseSlug(entry.fullPath, entry.slug),
|
|
894
|
+
}))
|
|
895
|
+
);
|
|
896
|
+
pythonRstRendererAvailable = true;
|
|
897
|
+
} catch (error) {
|
|
898
|
+
if (isPythonRuntimeUnavailable(error)) {
|
|
899
|
+
pythonRstRendererAvailable = false;
|
|
900
|
+
} else {
|
|
901
|
+
throw error;
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
pendingRstPosts.forEach(entry => {
|
|
907
|
+
allPostsData.push(
|
|
908
|
+
parseRstFile(
|
|
909
|
+
entry.fullPath,
|
|
910
|
+
entry.slug,
|
|
911
|
+
entry.dateFromFileName,
|
|
912
|
+
entry.seriesSlug,
|
|
913
|
+
batchRenderedByFile?.get(entry.fullPath),
|
|
914
|
+
)
|
|
915
|
+
);
|
|
916
|
+
});
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
const result = allPostsData
|
|
438
920
|
.filter(post => {
|
|
439
921
|
if (post.category === 'Page') return false;
|
|
440
922
|
|
|
@@ -450,6 +932,8 @@ export function getAllPosts(): PostData[] {
|
|
|
450
932
|
return true;
|
|
451
933
|
})
|
|
452
934
|
.sort((a, b) => (a.date < b.date ? 1 : -1));
|
|
935
|
+
postsCache.set(cacheKey, result);
|
|
936
|
+
return result;
|
|
453
937
|
}
|
|
454
938
|
|
|
455
939
|
/**
|
|
@@ -463,109 +947,8 @@ export function getListingPosts(): PostData[] {
|
|
|
463
947
|
return getAllPosts().filter(p => !p.series || !excluded.has(p.series));
|
|
464
948
|
}
|
|
465
949
|
|
|
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
950
|
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;
|
|
951
|
+
return getAllPosts().find(post => post.slug === slug) ?? null;
|
|
569
952
|
}
|
|
570
953
|
|
|
571
954
|
/**
|
|
@@ -621,8 +1004,12 @@ export function getPageBySlug(slug: string): PostData | null {
|
|
|
621
1004
|
}
|
|
622
1005
|
|
|
623
1006
|
export function getAllPages(): PostData[] {
|
|
1007
|
+
const cacheKey = getCacheEnvKey();
|
|
1008
|
+
const cached = pagesCache.get(cacheKey);
|
|
1009
|
+
if (cached) return cached;
|
|
1010
|
+
|
|
624
1011
|
const items = fs.readdirSync(pagesDirectory, { withFileTypes: true });
|
|
625
|
-
|
|
1012
|
+
const result = items
|
|
626
1013
|
.filter(item => {
|
|
627
1014
|
if (!item.isFile()) return false;
|
|
628
1015
|
if (!item.name.endsWith('.mdx') && !item.name.endsWith('.md')) return false;
|
|
@@ -639,6 +1026,8 @@ export function getAllPages(): PostData[] {
|
|
|
639
1026
|
const fullPath = path.join(pagesDirectory, item.name);
|
|
640
1027
|
return attachContentLocales(parseMarkdownFile(fullPath, slug), slug);
|
|
641
1028
|
});
|
|
1029
|
+
pagesCache.set(cacheKey, result);
|
|
1030
|
+
return result;
|
|
642
1031
|
}
|
|
643
1032
|
|
|
644
1033
|
export function getPostsByTag(tag: string): PostData[] {
|
|
@@ -661,6 +1050,10 @@ export function getFlowTags(): Record<string, number> {
|
|
|
661
1050
|
}
|
|
662
1051
|
|
|
663
1052
|
export function getAllTags(): Record<string, number> {
|
|
1053
|
+
const cacheKey = getCacheEnvKey();
|
|
1054
|
+
const cached = tagsCache.get(cacheKey);
|
|
1055
|
+
if (cached) return cached;
|
|
1056
|
+
|
|
664
1057
|
const allPosts = getAllPosts();
|
|
665
1058
|
const allFlows = getAllFlows();
|
|
666
1059
|
const allNotes = getAllNotes();
|
|
@@ -696,6 +1089,7 @@ export function getAllTags(): Record<string, number> {
|
|
|
696
1089
|
for (const [key, count] of Object.entries(counts)) {
|
|
697
1090
|
result[display[key]] = count;
|
|
698
1091
|
}
|
|
1092
|
+
tagsCache.set(cacheKey, result);
|
|
699
1093
|
return result;
|
|
700
1094
|
}
|
|
701
1095
|
|
|
@@ -717,6 +1111,10 @@ export function getAuthorSlug(author: string): string {
|
|
|
717
1111
|
}
|
|
718
1112
|
|
|
719
1113
|
export function getAllAuthors(): Record<string, number> {
|
|
1114
|
+
const cacheKey = getCacheEnvKey();
|
|
1115
|
+
const cached = authorsCache.get(cacheKey);
|
|
1116
|
+
if (cached) return cached;
|
|
1117
|
+
|
|
720
1118
|
const allPosts = getAllPosts();
|
|
721
1119
|
const authors: Record<string, number> = {};
|
|
722
1120
|
|
|
@@ -729,7 +1127,7 @@ export function getAllAuthors(): Record<string, number> {
|
|
|
729
1127
|
}
|
|
730
1128
|
});
|
|
731
1129
|
});
|
|
732
|
-
|
|
1130
|
+
authorsCache.set(cacheKey, authors);
|
|
733
1131
|
return authors;
|
|
734
1132
|
}
|
|
735
1133
|
|
|
@@ -747,6 +1145,16 @@ export function resolveAuthorParam(authorParam: string): string | null {
|
|
|
747
1145
|
}
|
|
748
1146
|
|
|
749
1147
|
export function getRelatedPosts(currentSlug: string, limit: number = 3): PostData[] {
|
|
1148
|
+
const cacheKey = getCacheEnvKey();
|
|
1149
|
+
let bySlug = relatedPostsCache.get(cacheKey);
|
|
1150
|
+
if (!bySlug) {
|
|
1151
|
+
bySlug = new Map();
|
|
1152
|
+
relatedPostsCache.set(cacheKey, bySlug);
|
|
1153
|
+
}
|
|
1154
|
+
const cacheId = `${currentSlug}:${limit}`;
|
|
1155
|
+
const cached = bySlug.get(cacheId);
|
|
1156
|
+
if (cached) return cached;
|
|
1157
|
+
|
|
750
1158
|
const allPosts = getAllPosts();
|
|
751
1159
|
const currentPost = allPosts.find(p => p.slug === currentSlug);
|
|
752
1160
|
|
|
@@ -770,10 +1178,20 @@ export function getRelatedPosts(currentSlug: string, limit: number = 3): PostDat
|
|
|
770
1178
|
.slice(0, limit)
|
|
771
1179
|
.map(item => item.post);
|
|
772
1180
|
|
|
1181
|
+
bySlug.set(cacheId, related);
|
|
773
1182
|
return related;
|
|
774
1183
|
}
|
|
775
1184
|
|
|
776
1185
|
export function getSeriesPosts(seriesName: string): PostData[] {
|
|
1186
|
+
const cacheKey = getCacheEnvKey();
|
|
1187
|
+
let bySeries = seriesPostsCache.get(cacheKey);
|
|
1188
|
+
if (!bySeries) {
|
|
1189
|
+
bySeries = new Map();
|
|
1190
|
+
seriesPostsCache.set(cacheKey, bySeries);
|
|
1191
|
+
}
|
|
1192
|
+
const cached = bySeries.get(seriesName);
|
|
1193
|
+
if (cached) return cached;
|
|
1194
|
+
|
|
777
1195
|
const seriesSlug = seriesName;
|
|
778
1196
|
const seriesData = getSeriesData(seriesSlug);
|
|
779
1197
|
|
|
@@ -798,10 +1216,15 @@ export function getSeriesPosts(seriesName: string): PostData[] {
|
|
|
798
1216
|
}
|
|
799
1217
|
}
|
|
800
1218
|
|
|
1219
|
+
bySeries.set(seriesName, posts);
|
|
801
1220
|
return posts;
|
|
802
1221
|
}
|
|
803
1222
|
|
|
804
1223
|
export function getAllSeries(): Record<string, PostData[]> {
|
|
1224
|
+
const cacheKey = getCacheEnvKey();
|
|
1225
|
+
const cached = allSeriesCache.get(cacheKey);
|
|
1226
|
+
if (cached) return cached;
|
|
1227
|
+
|
|
805
1228
|
const allPosts = getAllPosts();
|
|
806
1229
|
const series: Record<string, PostData[]> = {};
|
|
807
1230
|
const seriesSet = new Set<string>();
|
|
@@ -834,25 +1257,89 @@ export function getAllSeries(): Record<string, PostData[]> {
|
|
|
834
1257
|
: getSeriesPosts(slug);
|
|
835
1258
|
});
|
|
836
1259
|
|
|
1260
|
+
allSeriesCache.set(cacheKey, series);
|
|
837
1261
|
return series;
|
|
838
1262
|
}
|
|
839
1263
|
|
|
1264
|
+
export function getSeriesLatestPostDate(slug: string): string {
|
|
1265
|
+
const cacheKey = getCacheEnvKey();
|
|
1266
|
+
let bySlug = seriesLatestDateCache.get(cacheKey);
|
|
1267
|
+
if (!bySlug) {
|
|
1268
|
+
bySlug = new Map();
|
|
1269
|
+
seriesLatestDateCache.set(cacheKey, bySlug);
|
|
1270
|
+
}
|
|
1271
|
+
const cached = bySlug.get(slug);
|
|
1272
|
+
if (cached !== undefined) return cached;
|
|
1273
|
+
|
|
1274
|
+
const seriesData = getSeriesData(slug);
|
|
1275
|
+
const posts = seriesData?.type === 'collection' ? getCollectionPosts(slug) : getSeriesPosts(slug);
|
|
1276
|
+
const latestPostDate = posts.reduce((latest, post) => (post.date > latest ? post.date : latest), '');
|
|
1277
|
+
const resolved = latestPostDate || seriesData?.date || '';
|
|
1278
|
+
|
|
1279
|
+
bySlug.set(slug, resolved);
|
|
1280
|
+
return resolved;
|
|
1281
|
+
}
|
|
1282
|
+
|
|
840
1283
|
export function getFeaturedPosts(): PostData[] {
|
|
841
|
-
const
|
|
842
|
-
|
|
1284
|
+
const cacheKey = getCacheEnvKey();
|
|
1285
|
+
const cached = featuredPostsCache.get(cacheKey);
|
|
1286
|
+
if (cached) return cached;
|
|
1287
|
+
const result = getAllPosts().filter(post => post.featured);
|
|
1288
|
+
featuredPostsCache.set(cacheKey, result);
|
|
1289
|
+
return result;
|
|
843
1290
|
}
|
|
844
1291
|
|
|
845
1292
|
export function getAdjacentPosts(slug: string): { prev: PostData | null; next: PostData | null } {
|
|
1293
|
+
const cacheKey = getCacheEnvKey();
|
|
1294
|
+
let bySlug = adjacentPostsCache.get(cacheKey);
|
|
1295
|
+
if (!bySlug) {
|
|
1296
|
+
bySlug = new Map();
|
|
1297
|
+
adjacentPostsCache.set(cacheKey, bySlug);
|
|
1298
|
+
}
|
|
1299
|
+
const currentPost = getPostBySlug(slug);
|
|
1300
|
+
if (currentPost?.series) {
|
|
1301
|
+
const seriesCacheKey = `${currentPost.series}/${slug}`;
|
|
1302
|
+
const cachedSeries = bySlug.get(seriesCacheKey);
|
|
1303
|
+
if (cachedSeries) return cachedSeries;
|
|
1304
|
+
|
|
1305
|
+
const seriesData = getSeriesData(currentPost.series);
|
|
1306
|
+
if (seriesData?.type !== 'collection') {
|
|
1307
|
+
const seriesPosts = getSeriesPosts(currentPost.series);
|
|
1308
|
+
const seriesIndex = seriesPosts.findIndex(post => post.slug === slug);
|
|
1309
|
+
if (seriesIndex !== -1) {
|
|
1310
|
+
const seriesResult = {
|
|
1311
|
+
prev: seriesIndex > 0 ? seriesPosts[seriesIndex - 1] : null,
|
|
1312
|
+
next: seriesIndex < seriesPosts.length - 1 ? seriesPosts[seriesIndex + 1] : null,
|
|
1313
|
+
};
|
|
1314
|
+
bySlug.set(seriesCacheKey, seriesResult);
|
|
1315
|
+
return seriesResult;
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
const cached = bySlug.get(slug);
|
|
1321
|
+
if (cached) return cached;
|
|
1322
|
+
|
|
846
1323
|
const allPosts = getAllPosts(); // sorted desc by date (newest first)
|
|
847
1324
|
const index = allPosts.findIndex(p => p.slug === slug);
|
|
848
|
-
if (index === -1)
|
|
849
|
-
|
|
1325
|
+
if (index === -1) {
|
|
1326
|
+
const empty = { prev: null, next: null };
|
|
1327
|
+
bySlug.set(slug, empty);
|
|
1328
|
+
return empty;
|
|
1329
|
+
}
|
|
1330
|
+
const result = {
|
|
850
1331
|
prev: index < allPosts.length - 1 ? allPosts[index + 1] : null, // older post
|
|
851
1332
|
next: index > 0 ? allPosts[index - 1] : null, // newer post
|
|
852
1333
|
};
|
|
1334
|
+
bySlug.set(slug, result);
|
|
1335
|
+
return result;
|
|
853
1336
|
}
|
|
854
1337
|
|
|
855
1338
|
export function getFeaturedSeries(): Record<string, PostData[]> {
|
|
1339
|
+
const cacheKey = getCacheEnvKey();
|
|
1340
|
+
const cached = featuredSeriesCache.get(cacheKey);
|
|
1341
|
+
if (cached) return cached;
|
|
1342
|
+
|
|
856
1343
|
const allSeries = getAllSeries();
|
|
857
1344
|
const featuredSeries: Record<string, PostData[]> = {};
|
|
858
1345
|
|
|
@@ -863,25 +1350,47 @@ export function getFeaturedSeries(): Record<string, PostData[]> {
|
|
|
863
1350
|
}
|
|
864
1351
|
});
|
|
865
1352
|
|
|
1353
|
+
featuredSeriesCache.set(cacheKey, featuredSeries);
|
|
866
1354
|
return featuredSeries;
|
|
867
1355
|
}
|
|
868
1356
|
|
|
869
1357
|
export function getSeriesData(slug: string): PostData | null {
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
1358
|
+
const cacheKey = getCacheEnvKey();
|
|
1359
|
+
let bySlug = seriesDataCache.get(cacheKey);
|
|
1360
|
+
if (!bySlug) {
|
|
1361
|
+
bySlug = new Map();
|
|
1362
|
+
seriesDataCache.set(cacheKey, bySlug);
|
|
1363
|
+
}
|
|
1364
|
+
if (bySlug.has(slug)) return bySlug.get(slug) ?? null;
|
|
873
1365
|
|
|
874
|
-
|
|
875
|
-
if (
|
|
876
|
-
|
|
877
|
-
|
|
1366
|
+
const indexInfo = resolveSeriesIndexInfo(slug);
|
|
1367
|
+
if (!indexInfo) {
|
|
1368
|
+
bySlug.set(slug, null);
|
|
1369
|
+
return null;
|
|
1370
|
+
}
|
|
878
1371
|
|
|
879
|
-
|
|
1372
|
+
const result = indexInfo.format === 'rst'
|
|
1373
|
+
? parseRstFile(indexInfo.fullPath, slug, undefined, slug)
|
|
1374
|
+
: parseMarkdownFile(indexInfo.fullPath, slug, undefined, slug);
|
|
1375
|
+
bySlug.set(slug, result);
|
|
1376
|
+
return result;
|
|
880
1377
|
}
|
|
881
1378
|
|
|
882
1379
|
export function getCollectionPosts(collectionSlug: string): PostData[] {
|
|
1380
|
+
const cacheKey = getCacheEnvKey();
|
|
1381
|
+
let bySlug = collectionPostsCache.get(cacheKey);
|
|
1382
|
+
if (!bySlug) {
|
|
1383
|
+
bySlug = new Map();
|
|
1384
|
+
collectionPostsCache.set(cacheKey, bySlug);
|
|
1385
|
+
}
|
|
1386
|
+
const cached = bySlug.get(collectionSlug);
|
|
1387
|
+
if (cached) return cached;
|
|
1388
|
+
|
|
883
1389
|
const data = getSeriesData(collectionSlug);
|
|
884
|
-
if (data?.type !== 'collection' || !data.items)
|
|
1390
|
+
if (data?.type !== 'collection' || !data.items) {
|
|
1391
|
+
bySlug.set(collectionSlug, []);
|
|
1392
|
+
return [];
|
|
1393
|
+
}
|
|
885
1394
|
|
|
886
1395
|
const getCollectionKey = (post: PostData) =>
|
|
887
1396
|
post.series ? `${post.series}/${post.slug}` : `posts/${post.slug}`;
|
|
@@ -890,7 +1399,7 @@ export function getCollectionPosts(collectionSlug: string): PostData[] {
|
|
|
890
1399
|
const postIndex = new Map(allPosts.map((post) => [getCollectionKey(post), post]));
|
|
891
1400
|
const seen = new Set<string>();
|
|
892
1401
|
|
|
893
|
-
|
|
1402
|
+
const result = data.items
|
|
894
1403
|
.flatMap(item => {
|
|
895
1404
|
if ('series' in item) {
|
|
896
1405
|
const posts = getSeriesPosts(item.series);
|
|
@@ -909,9 +1418,20 @@ export function getCollectionPosts(collectionSlug: string): PostData[] {
|
|
|
909
1418
|
seen.add(key);
|
|
910
1419
|
return true;
|
|
911
1420
|
});
|
|
1421
|
+
bySlug.set(collectionSlug, result);
|
|
1422
|
+
return result;
|
|
912
1423
|
}
|
|
913
1424
|
|
|
914
1425
|
export function getCollectionsForPost(postSlug: string): CollectionContext[] {
|
|
1426
|
+
const cacheKey = getCacheEnvKey();
|
|
1427
|
+
let bySlug = collectionsForPostCache.get(cacheKey);
|
|
1428
|
+
if (!bySlug) {
|
|
1429
|
+
bySlug = new Map();
|
|
1430
|
+
collectionsForPostCache.set(cacheKey, bySlug);
|
|
1431
|
+
}
|
|
1432
|
+
const cached = bySlug.get(postSlug);
|
|
1433
|
+
if (cached) return cached;
|
|
1434
|
+
|
|
915
1435
|
if (!fs.existsSync(seriesDirectory)) return [];
|
|
916
1436
|
const seriesFolders = fs.readdirSync(seriesDirectory, { withFileTypes: true });
|
|
917
1437
|
const results: CollectionContext[] = [];
|
|
@@ -927,6 +1447,7 @@ export function getCollectionsForPost(postSlug: string): CollectionContext[] {
|
|
|
927
1447
|
}
|
|
928
1448
|
}
|
|
929
1449
|
|
|
1450
|
+
bySlug.set(postSlug, results);
|
|
930
1451
|
return results;
|
|
931
1452
|
}
|
|
932
1453
|
|