@hutusi/amytis 1.16.0 → 1.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/rules/immersive-reading.md +21 -0
- package/.claude/rules/rst.md +13 -0
- package/CHANGELOG.md +16 -0
- package/CLAUDE.md +10 -11
- package/docs/ARCHITECTURE.md +81 -0
- package/docs/DIGITAL_GARDEN.md +1 -1
- package/docs/guides/importing-vuepress-books.md +95 -36
- package/package.json +1 -1
- package/scripts/sync-vuepress-book.ts +277 -66
- package/site.config.example.ts +3 -3
- package/site.config.ts +3 -3
- package/src/app/[slug]/layout.tsx +30 -0
- package/src/app/books/[slug]/layout.tsx +24 -0
- package/src/app/books/[slug]/page.tsx +18 -2
- package/src/app/globals.css +67 -0
- package/src/app/page.tsx +6 -0
- package/src/app/posts/layout.tsx +20 -0
- package/src/app/series/[slug]/page.tsx +33 -9
- package/src/components/BookReadingShell.tsx +145 -0
- package/src/components/BookSidebar.tsx +0 -0
- package/src/components/CuratedSeriesSection.tsx +28 -10
- package/src/components/FeaturedStoriesSection.tsx +41 -20
- package/src/components/Footer.tsx +1 -1
- package/src/components/ImmersiveReader.tsx +130 -0
- package/src/components/ImmersiveReaderTopBar.tsx +106 -0
- package/src/components/ImmersiveReadingFlagHandler.tsx +40 -0
- package/src/components/ImmersiveReadingPrefsPopover.tsx +249 -0
- package/src/components/ImmersiveReadingProvider.tsx +168 -0
- package/src/components/ImmersiveSeriesSidebar.tsx +143 -0
- package/src/components/ImmersiveToggleButton.tsx +45 -0
- package/src/components/MarkdownRenderer.tsx +31 -0
- package/src/components/Navbar.tsx +3 -1
- package/src/components/PostReadingShell.tsx +68 -0
- package/src/components/ReadingProgressBar.tsx +1 -1
- package/src/components/SelectedBooksSection.tsx +27 -8
- package/src/hooks/useActiveHeading.ts +35 -13
- package/src/hooks/useSidebarAutoScroll.ts +31 -7
- package/src/i18n/translations.ts +42 -0
- package/src/layouts/BookLayout.tsx +46 -89
- package/src/layouts/PostLayout.tsx +154 -115
- package/src/lib/immersive-reading-prefs.ts +104 -0
- package/src/lib/markdown.ts +18 -11
- package/src/lib/scroll-utils.ts +44 -6
- package/src/lib/shuffle.ts +15 -1
- package/src/lib/sort.ts +15 -0
- package/src/lib/urls.ts +5 -0
- package/tests/integration/book-index-cta.test.ts +87 -0
- package/tests/integration/series-index-cta.test.ts +88 -0
- package/tests/integration/sync-vuepress-book.test.ts +205 -2
- package/tests/unit/immersive-reading-prefs.test.ts +144 -0
- package/vercel.json +7 -0
|
@@ -8,11 +8,15 @@ import type * as acorn from 'acorn';
|
|
|
8
8
|
// bun run sync-vuepress-book --source <vuepress-docs-dir> --dest <amytis-book-dir>
|
|
9
9
|
// bun run sync-vuepress-book <source> <dest> (positional shorthand)
|
|
10
10
|
//
|
|
11
|
-
// Walks a VuePress
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
11
|
+
// Walks a VuePress project's `.vuepress/config.{js,mjs}`, extracts the sidebar
|
|
12
|
+
// literal via AST parsing, converts it to the nested {section, items} TOC
|
|
13
|
+
// format Amytis books support natively, copies the source markdown + asset
|
|
14
|
+
// tree into the destination, and rewrites the dest's index.mdx with the new
|
|
15
|
+
// TOC (preserving user-controlled frontmatter fields).
|
|
16
|
+
//
|
|
17
|
+
// Supports both VuePress 2 (`{ text, link }` / `{ text, children }`) and
|
|
18
|
+
// VuePress 1 (`{ title, path }` / `{ title, children }`, plus bare string
|
|
19
|
+
// child paths and `path + children` group-with-index pages) sidebar shapes.
|
|
16
20
|
//
|
|
17
21
|
// Re-runnable: any subsequent run mirrors the current state of the source.
|
|
18
22
|
|
|
@@ -21,18 +25,43 @@ import type * as acorn from 'acorn';
|
|
|
21
25
|
interface CliArgs {
|
|
22
26
|
source: string;
|
|
23
27
|
dest: string;
|
|
28
|
+
skipCommon: boolean;
|
|
29
|
+
skipPatterns: string[];
|
|
24
30
|
}
|
|
25
31
|
|
|
32
|
+
// Common build manifests / lockfiles that VuePress books carry at their
|
|
33
|
+
// repo root but which are never book content. Authors who genuinely want
|
|
34
|
+
// these synced into `content/books/<slug>/` can pass `--no-skip-common`.
|
|
35
|
+
const COMMON_SKIP_FILENAMES = [
|
|
36
|
+
'package.json',
|
|
37
|
+
'package-lock.json',
|
|
38
|
+
'yarn.lock',
|
|
39
|
+
'pnpm-lock.yaml',
|
|
40
|
+
'bun.lock',
|
|
41
|
+
'bun.lockb',
|
|
42
|
+
];
|
|
43
|
+
|
|
26
44
|
function parseArgs(argv: string[]): CliArgs {
|
|
27
45
|
const positional: string[] = [];
|
|
28
46
|
let source: string | undefined;
|
|
29
47
|
let dest: string | undefined;
|
|
48
|
+
let skipCommon = true;
|
|
49
|
+
const skipPatterns: string[] = [];
|
|
50
|
+
const pushSkip = (raw: string) => {
|
|
51
|
+
for (const p of raw.split(',').map(s => s.trim()).filter(Boolean)) {
|
|
52
|
+
skipPatterns.push(p);
|
|
53
|
+
}
|
|
54
|
+
};
|
|
30
55
|
for (let i = 0; i < argv.length; i++) {
|
|
31
56
|
const a = argv[i];
|
|
32
57
|
if (a === '--source') { source = argv[++i]; continue; }
|
|
33
58
|
if (a === '--dest') { dest = argv[++i]; continue; }
|
|
34
59
|
if (a.startsWith('--source=')) { source = a.slice('--source='.length); continue; }
|
|
35
60
|
if (a.startsWith('--dest=')) { dest = a.slice('--dest='.length); continue; }
|
|
61
|
+
if (a === '--skip-common') { skipCommon = true; continue; }
|
|
62
|
+
if (a === '--no-skip-common') { skipCommon = false; continue; }
|
|
63
|
+
if (a === '--skip') { pushSkip(argv[++i] ?? ''); continue; }
|
|
64
|
+
if (a.startsWith('--skip=')) { pushSkip(a.slice('--skip='.length)); continue; }
|
|
36
65
|
if (a === '--help' || a === '-h') {
|
|
37
66
|
printUsageAndExit(0);
|
|
38
67
|
}
|
|
@@ -44,16 +73,30 @@ function parseArgs(argv: string[]): CliArgs {
|
|
|
44
73
|
return {
|
|
45
74
|
source: path.resolve(source!),
|
|
46
75
|
dest: path.resolve(dest!),
|
|
76
|
+
skipCommon,
|
|
77
|
+
skipPatterns,
|
|
47
78
|
};
|
|
48
79
|
}
|
|
49
80
|
|
|
50
81
|
function printUsageAndExit(code: number): never {
|
|
51
82
|
console.error(
|
|
52
83
|
'Usage: bun run sync-vuepress-book --source <vuepress-docs-dir> --dest <amytis-book-dir>\n' +
|
|
84
|
+
' [--no-skip-common] [--skip <pattern,pattern,…>]\n' +
|
|
85
|
+
'\n' +
|
|
86
|
+
'Options:\n' +
|
|
87
|
+
' --source <dir> VuePress docs root (the parent of `.vuepress/`).\n' +
|
|
88
|
+
' --dest <dir> Amytis book dir to write to (typically `content/books/<slug>`).\n' +
|
|
89
|
+
' --skip-common Skip lockfiles + package manifests (default: on).\n' +
|
|
90
|
+
' Filenames: ' + COMMON_SKIP_FILENAMES.join(', ') + '.\n' +
|
|
91
|
+
' --no-skip-common Disable the common skip list (copy everything).\n' +
|
|
92
|
+
' --skip <pat,pat,…> Skip files whose basename matches any of the\n' +
|
|
93
|
+
' given glob patterns. Repeatable. Applied to\n' +
|
|
94
|
+
' both files and directories. Examples:\n' +
|
|
95
|
+
' --skip "*.bak,Dockerfile,build"\n' +
|
|
53
96
|
'\n' +
|
|
54
97
|
'Examples:\n' +
|
|
55
98
|
' bun run sync-vuepress-book --source /path/to/dmla/docs --dest content/books/dmla\n' +
|
|
56
|
-
' bun run sync-vuepress-book /path/to/dmla/docs content/books/dmla'
|
|
99
|
+
' bun run sync-vuepress-book /path/to/dmla/docs content/books/dmla --skip "*.bak,dist"'
|
|
57
100
|
);
|
|
58
101
|
process.exit(code);
|
|
59
102
|
}
|
|
@@ -208,7 +251,9 @@ type TocItem = Section | ChapterRef;
|
|
|
208
251
|
function normalizeLink(link: string): string {
|
|
209
252
|
// VuePress sidebar links may use any of: leading slash, no slash, trailing
|
|
210
253
|
// slash (folder-index style like `/guide/`), or an explicit `.md`/`.mdx`
|
|
211
|
-
// suffix. The canonical Amytis chapter id has none of those
|
|
254
|
+
// suffix. The canonical Amytis chapter id has none of those — trailing
|
|
255
|
+
// slashes are stripped and `resolveSourceFile` finds the `<id>/README.md`
|
|
256
|
+
// companion via its candidate list.
|
|
212
257
|
let s: string;
|
|
213
258
|
try {
|
|
214
259
|
s = decodeURIComponent(link.trim());
|
|
@@ -222,66 +267,190 @@ function normalizeLink(link: string): string {
|
|
|
222
267
|
return s;
|
|
223
268
|
}
|
|
224
269
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
270
|
+
// Sidebar leaves whose normalized id matches one of these (case-insensitive)
|
|
271
|
+
// are dropped from the generated TOC. They're VuePress / GitBook conventions
|
|
272
|
+
// for a hand-written table-of-contents page that duplicates what Amytis's
|
|
273
|
+
// book landing page already renders. `SUMMARY` covers GitBook-style
|
|
274
|
+
// `SUMMARY.md` entries common in VP1 imports.
|
|
275
|
+
const SKIPPED_LEAF_IDS = new Set(['contents', 'summary']);
|
|
228
276
|
|
|
229
|
-
function
|
|
230
|
-
|
|
277
|
+
function isSkippedMetaLeaf(id: string): boolean {
|
|
278
|
+
const tail = id.split('/').pop() ?? id;
|
|
279
|
+
return SKIPPED_LEAF_IDS.has(tail.toLowerCase());
|
|
231
280
|
}
|
|
232
281
|
|
|
233
|
-
// Sidebar leaves whose normalized id matches one of these are dropped from the
|
|
234
|
-
// generated TOC. They're VuePress conventions for a hand-written table-of-
|
|
235
|
-
// contents page that duplicates what Amytis's book landing page already renders.
|
|
236
|
-
const SKIPPED_LEAF_IDS = new Set(['contents']);
|
|
237
|
-
|
|
238
282
|
interface ConvertWarnings {
|
|
239
283
|
emptySections: string[]; // sections with no items
|
|
240
|
-
sectionWithOwnLink: string[]; // ignored own-page link on a group header
|
|
241
284
|
unsupported: string[]; // strings or other forms we skip
|
|
242
285
|
skippedMetaLeaves: string[]; // leaves dropped because their id is a known meta-nav slug
|
|
243
286
|
}
|
|
244
287
|
|
|
245
|
-
|
|
288
|
+
/**
|
|
289
|
+
* Common shape for both VP1 and VP2 sidebar entries. Produced by
|
|
290
|
+
* `normalizeRawEntry` so the downstream walker only deals with one schema.
|
|
291
|
+
*/
|
|
292
|
+
interface NormalizedEntry {
|
|
293
|
+
title?: string; // missing for bare-string entries
|
|
294
|
+
path?: string; // normalized via normalizeLink()
|
|
295
|
+
children?: SidebarItem[];
|
|
296
|
+
collapsible?: boolean;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Collapses a VuePress 1.x or 2.x sidebar entry into the common
|
|
301
|
+
* `NormalizedEntry` shape, returning `null` for anything we can't recognize.
|
|
302
|
+
*
|
|
303
|
+
* - VP2 leaf: `{ text, link }` → `{ title, path }`
|
|
304
|
+
* - VP2 section: `{ text, children, collapsible? }`
|
|
305
|
+
* - VP1 leaf: `{ title, path }` (no children)
|
|
306
|
+
* - VP1 section: `{ title, children, collapsable? }`
|
|
307
|
+
* - VP1 indexed: `{ title, path, children }` (README promoted later)
|
|
308
|
+
* - Bare string: `'/foo/bar'` → `{ path }` (title resolved
|
|
309
|
+
* from the source file)
|
|
310
|
+
*/
|
|
311
|
+
function normalizeRawEntry(raw: SidebarItem): NormalizedEntry | null {
|
|
312
|
+
if (typeof raw === 'string') {
|
|
313
|
+
return { path: raw };
|
|
314
|
+
}
|
|
315
|
+
if (!raw || typeof raw !== 'object') return null;
|
|
316
|
+
const item = raw as Record<string, unknown>;
|
|
317
|
+
|
|
318
|
+
const title = typeof item.text === 'string'
|
|
319
|
+
? item.text
|
|
320
|
+
: typeof item.title === 'string'
|
|
321
|
+
? item.title
|
|
322
|
+
: undefined;
|
|
323
|
+
const path = typeof item.link === 'string'
|
|
324
|
+
? item.link
|
|
325
|
+
: typeof item.path === 'string'
|
|
326
|
+
? item.path
|
|
327
|
+
: undefined;
|
|
328
|
+
const children = Array.isArray(item.children) ? (item.children as SidebarItem[]) : undefined;
|
|
329
|
+
|
|
330
|
+
// VP2 uses `collapsible` (boolean); VP1 uses `collapsable` (boolean meaning
|
|
331
|
+
// "user may collapse"). Both map to the same Amytis hint.
|
|
332
|
+
let collapsible: boolean | undefined;
|
|
333
|
+
if (typeof item.collapsible === 'boolean') collapsible = item.collapsible;
|
|
334
|
+
else if (typeof item.collapsable === 'boolean') collapsible = item.collapsable;
|
|
335
|
+
|
|
336
|
+
// Must carry at least a title, a path, or children — otherwise there's
|
|
337
|
+
// nothing to convert. (Bare strings are already handled above.)
|
|
338
|
+
if (!title && !path && !children) return null;
|
|
339
|
+
|
|
340
|
+
return { title, path, children, collapsible };
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Reads a chapter title from a source markdown file. Tries frontmatter
|
|
345
|
+
* `title` first, then the first H1 in the body, then falls back to a
|
|
346
|
+
* titleized slug. Used when the sidebar entry was a bare string (VP1 style)
|
|
347
|
+
* or when we're promoting a section's README as a chapter.
|
|
348
|
+
*
|
|
349
|
+
* Returns `null` if the file can't be read — caller decides the fallback.
|
|
350
|
+
*/
|
|
351
|
+
function readTitleFromSource(absPath: string | null): string | null {
|
|
352
|
+
if (!absPath || !fs.existsSync(absPath)) return null;
|
|
353
|
+
try {
|
|
354
|
+
const raw = fs.readFileSync(absPath, 'utf8');
|
|
355
|
+
const parsed = matter(raw);
|
|
356
|
+
const fmTitle = (parsed.data as { title?: unknown }).title;
|
|
357
|
+
if (typeof fmTitle === 'string' && fmTitle.trim()) return fmTitle.trim();
|
|
358
|
+
const h1 = parsed.content.match(/^\s*#\s+(.+?)\s*$/m);
|
|
359
|
+
if (h1) return h1[1].trim();
|
|
360
|
+
} catch {
|
|
361
|
+
return null;
|
|
362
|
+
}
|
|
363
|
+
return null;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function titleizeSlug(id: string): string {
|
|
367
|
+
const tail = id.split('/').pop() ?? id;
|
|
368
|
+
return tail
|
|
369
|
+
.split(/[-_]/)
|
|
370
|
+
.filter(Boolean)
|
|
371
|
+
.map(s => s.charAt(0).toUpperCase() + s.slice(1))
|
|
372
|
+
.join(' ') || id;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function resolveTitle(
|
|
376
|
+
norm: NormalizedEntry,
|
|
377
|
+
id: string | null,
|
|
378
|
+
sourceDir: string,
|
|
379
|
+
): string {
|
|
380
|
+
if (norm.title) return norm.title;
|
|
381
|
+
if (id) {
|
|
382
|
+
const src = resolveSourceFile(sourceDir, id);
|
|
383
|
+
const fromFile = readTitleFromSource(src);
|
|
384
|
+
if (fromFile) return fromFile;
|
|
385
|
+
return titleizeSlug(id);
|
|
386
|
+
}
|
|
387
|
+
return '(untitled)';
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function convertSidebar(
|
|
391
|
+
sidebar: SidebarItem[],
|
|
392
|
+
sourceDir: string,
|
|
393
|
+
warnings: ConvertWarnings,
|
|
394
|
+
): TocItem[] {
|
|
246
395
|
const result: TocItem[] = [];
|
|
247
396
|
for (const raw of sidebar) {
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
}
|
|
252
|
-
const item = raw as Record<string, unknown>;
|
|
253
|
-
const text = typeof item.text === 'string' ? item.text : undefined;
|
|
254
|
-
if (!text) {
|
|
255
|
-
warnings.unsupported.push(JSON.stringify(item));
|
|
397
|
+
const norm = normalizeRawEntry(raw);
|
|
398
|
+
if (!norm) {
|
|
399
|
+
warnings.unsupported.push(typeof raw === 'string' ? raw : JSON.stringify(raw));
|
|
256
400
|
continue;
|
|
257
401
|
}
|
|
258
402
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
403
|
+
const hasChildren = !!norm.children && norm.children.length > 0;
|
|
404
|
+
const hasPath = typeof norm.path === 'string';
|
|
405
|
+
const pathId = hasPath ? normalizeLink(norm.path!) : null;
|
|
406
|
+
|
|
407
|
+
if (hasChildren) {
|
|
408
|
+
const items: Array<Section | ChapterRef> = [];
|
|
409
|
+
// VP1 sections often carry both a `path` (the section's README index
|
|
410
|
+
// page) and `children` (sub-chapters). Promote the README as the first
|
|
411
|
+
// chapter so its content stays reachable from the sidebar — matches
|
|
412
|
+
// VuePress UX where clicking the section title navigates to its README.
|
|
413
|
+
//
|
|
414
|
+
// Skip `norm.title` for the promoted chapter — it belongs to the
|
|
415
|
+
// section header. Read the chapter's own title from the README's
|
|
416
|
+
// frontmatter / H1 instead so the sidebar doesn't show the section
|
|
417
|
+
// name twice (once as the section, once as its first child).
|
|
418
|
+
if (pathId) {
|
|
419
|
+
if (isSkippedMetaLeaf(pathId)) {
|
|
420
|
+
warnings.skippedMetaLeaves.push(`${norm.title ?? pathId} (${pathId})`);
|
|
421
|
+
} else {
|
|
422
|
+
items.push({
|
|
423
|
+
title: resolveTitle({ ...norm, title: undefined }, pathId, sourceDir),
|
|
424
|
+
id: pathId,
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
items.push(...convertSidebar(norm.children!, sourceDir, warnings));
|
|
429
|
+
|
|
430
|
+
const sectionTitle = norm.title ?? '(untitled)';
|
|
431
|
+
const section: Section = { section: sectionTitle, items };
|
|
432
|
+
if (typeof norm.collapsible === 'boolean') section.collapsible = norm.collapsible;
|
|
433
|
+
if (items.length === 0) warnings.emptySections.push(sectionTitle);
|
|
268
434
|
result.push(section);
|
|
269
435
|
continue;
|
|
270
436
|
}
|
|
271
437
|
|
|
272
|
-
if (
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
warnings.skippedMetaLeaves.push(`${text} (${id})`);
|
|
438
|
+
if (hasPath && pathId) {
|
|
439
|
+
if (isSkippedMetaLeaf(pathId)) {
|
|
440
|
+
warnings.skippedMetaLeaves.push(`${norm.title ?? pathId} (${pathId})`);
|
|
276
441
|
continue;
|
|
277
442
|
}
|
|
278
|
-
result.push({
|
|
443
|
+
result.push({
|
|
444
|
+
title: resolveTitle(norm, pathId, sourceDir),
|
|
445
|
+
id: pathId,
|
|
446
|
+
});
|
|
279
447
|
continue;
|
|
280
448
|
}
|
|
281
449
|
|
|
282
|
-
// {
|
|
283
|
-
|
|
284
|
-
|
|
450
|
+
// {title, no path, no children} — a section header that's a placeholder.
|
|
451
|
+
const placeholderTitle = norm.title ?? '(untitled)';
|
|
452
|
+
warnings.emptySections.push(placeholderTitle);
|
|
453
|
+
result.push({ section: placeholderTitle, items: [] });
|
|
285
454
|
}
|
|
286
455
|
return result;
|
|
287
456
|
}
|
|
@@ -320,6 +489,22 @@ function resolveSourceFile(sourceDir: string, chapterId: string): string | null
|
|
|
320
489
|
|
|
321
490
|
const COPY_SKIP = new Set(['.vuepress', 'node_modules', '.git', '.DS_Store']);
|
|
322
491
|
|
|
492
|
+
/**
|
|
493
|
+
* Compiles a basename glob pattern (`*`, `?`, literal segments) into a
|
|
494
|
+
* RegExp. Patterns match the basename of a file or directory, never the
|
|
495
|
+
* full relative path — keeps the mental model close to `.gitignore`'s
|
|
496
|
+
* unanchored entries.
|
|
497
|
+
*/
|
|
498
|
+
function compileBasenameGlob(pattern: string): RegExp {
|
|
499
|
+
let re = '';
|
|
500
|
+
for (const ch of pattern) {
|
|
501
|
+
if (ch === '*') re += '.*';
|
|
502
|
+
else if (ch === '?') re += '.';
|
|
503
|
+
else re += ch.replace(/[.+^${}()|[\]\\]/g, '\\$&');
|
|
504
|
+
}
|
|
505
|
+
return new RegExp(`^${re}$`);
|
|
506
|
+
}
|
|
507
|
+
|
|
323
508
|
/**
|
|
324
509
|
* Files in the dest that are NOT in the source must NOT be pruned by the
|
|
325
510
|
* mirror: index.mdx is generated by writeIndexMdx (its frontmatter is the
|
|
@@ -333,6 +518,11 @@ function isDestManagedByImporter(relPath: string): boolean {
|
|
|
333
518
|
return true;
|
|
334
519
|
}
|
|
335
520
|
|
|
521
|
+
interface SyncOptions {
|
|
522
|
+
skipCommon: boolean;
|
|
523
|
+
skipPatterns: string[];
|
|
524
|
+
}
|
|
525
|
+
|
|
336
526
|
/**
|
|
337
527
|
* Mirror the source tree into the dest: copy every non-excluded file from
|
|
338
528
|
* source, then prune any importer-managed file under dest that doesn't
|
|
@@ -340,17 +530,31 @@ function isDestManagedByImporter(relPath: string): boolean {
|
|
|
340
530
|
* upstream rename or deletion — without the prune, stale content lingers
|
|
341
531
|
* in the dest and stays reachable.
|
|
342
532
|
*/
|
|
343
|
-
function syncTree(srcDir: string, destDir: string): { files: number; assets: number } {
|
|
533
|
+
function syncTree(srcDir: string, destDir: string, opts: SyncOptions): { files: number; assets: number; skipped: string[] } {
|
|
344
534
|
let files = 0;
|
|
345
535
|
let assets = 0;
|
|
536
|
+
const skipped: string[] = [];
|
|
346
537
|
if (!fs.existsSync(destDir)) fs.mkdirSync(destDir, { recursive: true });
|
|
347
538
|
|
|
539
|
+
const commonSkip = opts.skipCommon ? new Set(COMMON_SKIP_FILENAMES) : new Set<string>();
|
|
540
|
+
const customRegexes = opts.skipPatterns.map(compileBasenameGlob);
|
|
541
|
+
|
|
542
|
+
const shouldSkip = (name: string): boolean => {
|
|
543
|
+
if (commonSkip.has(name)) return true;
|
|
544
|
+
for (const re of customRegexes) if (re.test(name)) return true;
|
|
545
|
+
return false;
|
|
546
|
+
};
|
|
547
|
+
|
|
348
548
|
const sourceRelPaths = new Set<string>();
|
|
349
549
|
|
|
350
550
|
const walkSource = (src: string, dest: string, relBase: string) => {
|
|
351
551
|
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
|
|
352
552
|
if (COPY_SKIP.has(entry.name)) continue;
|
|
353
553
|
if (entry.name.startsWith('.')) continue;
|
|
554
|
+
if (shouldSkip(entry.name)) {
|
|
555
|
+
skipped.push(relBase ? path.join(relBase, entry.name) : entry.name);
|
|
556
|
+
continue;
|
|
557
|
+
}
|
|
354
558
|
const sPath = path.join(src, entry.name);
|
|
355
559
|
const dPath = path.join(dest, entry.name);
|
|
356
560
|
const relPath = relBase ? path.join(relBase, entry.name) : entry.name;
|
|
@@ -398,7 +602,7 @@ function syncTree(srcDir: string, destDir: string): { files: number; assets: num
|
|
|
398
602
|
};
|
|
399
603
|
prune(destDir, '');
|
|
400
604
|
|
|
401
|
-
return { files, assets };
|
|
605
|
+
return { files, assets, skipped };
|
|
402
606
|
}
|
|
403
607
|
|
|
404
608
|
// ─── index.mdx writing ───────────────────────────────────────────────────────
|
|
@@ -426,31 +630,38 @@ function loadVuepressTitle(configPath: string): string | undefined {
|
|
|
426
630
|
|
|
427
631
|
function writeIndexMdx(destDir: string, configPath: string, toc: TocItem[]): void {
|
|
428
632
|
const indexPath = path.join(destDir, 'index.mdx');
|
|
429
|
-
|
|
633
|
+
|
|
430
634
|
if (fs.existsSync(indexPath)) {
|
|
635
|
+
// Re-sync: the script owns `chapters:` and nothing else. Every other
|
|
636
|
+
// frontmatter key + the prose body is preserved as-is. Defaults that
|
|
637
|
+
// were sensible at first-sync time would now be unwanted overrides of
|
|
638
|
+
// what the author has chosen (including intentionally-blank values).
|
|
431
639
|
const raw = fs.readFileSync(indexPath, 'utf8');
|
|
432
640
|
const parsed = matter(raw);
|
|
433
|
-
|
|
641
|
+
const data: BookFrontmatter = { ...(parsed.data as BookFrontmatter), chapters: toc };
|
|
642
|
+
fs.writeFileSync(indexPath, matter.stringify(parsed.content, data));
|
|
643
|
+
return;
|
|
434
644
|
}
|
|
435
645
|
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
:
|
|
446
|
-
|
|
646
|
+
// First sync: bootstrap an index.mdx with the minimum the runtime's Zod
|
|
647
|
+
// book schema requires (`title:`) plus a couple of low-stakes defaults so
|
|
648
|
+
// the book is immediately loadable. The author edits to taste; subsequent
|
|
649
|
+
// re-syncs will preserve those edits.
|
|
650
|
+
const data: BookFrontmatter = {
|
|
651
|
+
title: loadVuepressTitle(configPath) ?? path.basename(destDir),
|
|
652
|
+
date: new Date().toISOString().split('T')[0],
|
|
653
|
+
draft: false,
|
|
654
|
+
featured: false,
|
|
655
|
+
chapters: toc,
|
|
656
|
+
};
|
|
657
|
+
const body = `\nImported from VuePress source at ${path.relative(process.cwd(), path.dirname(path.dirname(configPath)))}.\n`;
|
|
447
658
|
fs.writeFileSync(indexPath, matter.stringify(body, data));
|
|
448
659
|
}
|
|
449
660
|
|
|
450
661
|
// ─── Main ────────────────────────────────────────────────────────────────────
|
|
451
662
|
|
|
452
663
|
function main() {
|
|
453
|
-
const { source, dest } = parseArgs(process.argv.slice(2));
|
|
664
|
+
const { source, dest, skipCommon, skipPatterns } = parseArgs(process.argv.slice(2));
|
|
454
665
|
|
|
455
666
|
if (!fs.existsSync(source)) {
|
|
456
667
|
throw new Error(`[amytis] Source directory does not exist: ${source}`);
|
|
@@ -460,8 +671,8 @@ function main() {
|
|
|
460
671
|
console.log(`[sync-vuepress-book] Reading sidebar from ${path.relative(process.cwd(), configPath)}`);
|
|
461
672
|
|
|
462
673
|
const sidebar = extractSidebar(configPath);
|
|
463
|
-
const warnings: ConvertWarnings = { emptySections: [],
|
|
464
|
-
const toc = convertSidebar(sidebar, warnings);
|
|
674
|
+
const warnings: ConvertWarnings = { emptySections: [], unsupported: [], skippedMetaLeaves: [] };
|
|
675
|
+
const toc = convertSidebar(sidebar, source, warnings);
|
|
465
676
|
|
|
466
677
|
const chapters = collectChapterIds(toc);
|
|
467
678
|
const missing: string[] = [];
|
|
@@ -477,17 +688,17 @@ function main() {
|
|
|
477
688
|
}
|
|
478
689
|
|
|
479
690
|
console.log(`[sync-vuepress-book] Copying ${path.relative(process.cwd(), source)} → ${path.relative(process.cwd(), dest)}`);
|
|
480
|
-
const { files, assets } = syncTree(source, dest);
|
|
691
|
+
const { files, assets, skipped } = syncTree(source, dest, { skipCommon, skipPatterns });
|
|
481
692
|
|
|
482
693
|
writeIndexMdx(dest, configPath, toc);
|
|
483
694
|
|
|
484
695
|
console.log(`[sync-vuepress-book] Done. ${files} markdown files, ${assets} asset files copied, ${chapters.length} chapters mapped.`);
|
|
696
|
+
if (skipped.length > 0) {
|
|
697
|
+
console.log(`[sync-vuepress-book] Skipped ${skipped.length} file${skipped.length === 1 ? '' : 's'} matching skip rules: ${skipped.join(', ')}`);
|
|
698
|
+
}
|
|
485
699
|
if (warnings.emptySections.length > 0) {
|
|
486
700
|
console.warn(`[sync-vuepress-book] Empty sections (no items): ${warnings.emptySections.join(', ')}`);
|
|
487
701
|
}
|
|
488
|
-
if (warnings.sectionWithOwnLink.length > 0) {
|
|
489
|
-
console.warn(`[sync-vuepress-book] Sections with an own-page link were treated as pure groups; the link was dropped: ${warnings.sectionWithOwnLink.join(', ')}`);
|
|
490
|
-
}
|
|
491
702
|
if (warnings.unsupported.length > 0) {
|
|
492
703
|
console.warn(`[sync-vuepress-book] Skipped unsupported sidebar entries: ${warnings.unsupported.join(', ')}`);
|
|
493
704
|
}
|
package/site.config.example.ts
CHANGED
|
@@ -140,11 +140,11 @@ export const siteConfig = {
|
|
|
140
140
|
homepage: {
|
|
141
141
|
sections: [
|
|
142
142
|
{ id: 'hero', enabled: true, weight: 1 },
|
|
143
|
-
{ id: 'featured-posts', enabled: true, weight: 2, maxItems: 4 },
|
|
143
|
+
{ id: 'featured-posts', enabled: true, weight: 2, maxItems: 4, order: 'shuffle' as 'shuffle' | 'date-desc' | 'date-asc' },
|
|
144
144
|
{ id: 'latest-posts', enabled: true, weight: 3, maxItems: 3 },
|
|
145
145
|
{ id: 'recent-flows', enabled: false, weight: 4, maxItems: 8 },
|
|
146
|
-
{ id: 'featured-series', enabled: true, weight: 5, maxItems: 6 },
|
|
147
|
-
{ id: 'featured-books', enabled: false, weight: 6, maxItems: 4 },
|
|
146
|
+
{ id: 'featured-series', enabled: true, weight: 5, maxItems: 6, order: 'shuffle' as 'shuffle' | 'date-desc' | 'date-asc' },
|
|
147
|
+
{ id: 'featured-books', enabled: false, weight: 6, maxItems: 4, order: 'shuffle' as 'shuffle' | 'date-desc' | 'date-asc' },
|
|
148
148
|
],
|
|
149
149
|
},
|
|
150
150
|
|
package/site.config.ts
CHANGED
|
@@ -139,11 +139,11 @@ export const siteConfig = {
|
|
|
139
139
|
homepage: {
|
|
140
140
|
sections: [
|
|
141
141
|
{ id: 'hero', enabled: true, weight: 1 },
|
|
142
|
-
{ id: 'featured-posts', enabled: true, weight: 2, maxItems: 4 },
|
|
142
|
+
{ id: 'featured-posts', enabled: true, weight: 2, maxItems: 4, order: 'shuffle' as 'shuffle' | 'date-desc' | 'date-asc' },
|
|
143
143
|
{ id: 'latest-posts', enabled: true, weight: 3, maxItems: 4 },
|
|
144
144
|
{ id: 'recent-flows', enabled: true, weight: 4, maxItems: 7 },
|
|
145
|
-
{ id: 'featured-series', enabled: true, weight: 5, maxItems: 6 },
|
|
146
|
-
{ id: 'featured-books', enabled: true, weight: 6, maxItems: 4 },
|
|
145
|
+
{ id: 'featured-series', enabled: true, weight: 5, maxItems: 6, order: 'shuffle' as 'shuffle' | 'date-desc' | 'date-asc' },
|
|
146
|
+
{ id: 'featured-books', enabled: true, weight: 6, maxItems: 4, order: 'shuffle' as 'shuffle' | 'date-desc' | 'date-asc' },
|
|
147
147
|
],
|
|
148
148
|
},
|
|
149
149
|
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { Suspense, type ReactNode } from 'react';
|
|
2
|
+
import { ImmersiveReadingProvider } from '@/components/ImmersiveReadingProvider';
|
|
3
|
+
import ImmersiveReadingFlagHandler from '@/components/ImmersiveReadingFlagHandler';
|
|
4
|
+
|
|
5
|
+
// Mounts the immersive-reading state above the series-prefixed post route
|
|
6
|
+
// (`/<series-slug>/<post>` when series.autoPaths is enabled, which is the
|
|
7
|
+
// default). This is what lets immersive mode persist across client-side
|
|
8
|
+
// navigation between sibling posts in the same series — without it, the
|
|
9
|
+
// provider would remount on every post navigation and reader state would
|
|
10
|
+
// reset.
|
|
11
|
+
//
|
|
12
|
+
// Note this layout wraps ALL single-segment routes under `/`, not just series
|
|
13
|
+
// posts (also redirectFrom aliases, custom-path posts, etc.). The provider
|
|
14
|
+
// only activates when the toggle is clicked, and the toggle is gated on
|
|
15
|
+
// `post.series`, so non-series routes pay only the mount cost.
|
|
16
|
+
//
|
|
17
|
+
// The flag handler reads `?immersive=1` from the URL (set by the CTA on the
|
|
18
|
+
// series index page) and enters the reader. It's wrapped in <Suspense> on its
|
|
19
|
+
// own so its `useSearchParams` bailout doesn't drag {children} out of static
|
|
20
|
+
// prerender.
|
|
21
|
+
export default function SlugLayout({ children }: { children: ReactNode }) {
|
|
22
|
+
return (
|
|
23
|
+
<ImmersiveReadingProvider>
|
|
24
|
+
<Suspense fallback={null}>
|
|
25
|
+
<ImmersiveReadingFlagHandler />
|
|
26
|
+
</Suspense>
|
|
27
|
+
{children}
|
|
28
|
+
</ImmersiveReadingProvider>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { Suspense, type ReactNode } from 'react';
|
|
2
|
+
import { ImmersiveReadingProvider } from '@/components/ImmersiveReadingProvider';
|
|
3
|
+
import ImmersiveReadingFlagHandler from '@/components/ImmersiveReadingFlagHandler';
|
|
4
|
+
|
|
5
|
+
// Mounts the immersive-reading state above the chapter route. This is what
|
|
6
|
+
// lets immersive mode persist across client-side navigation between chapters
|
|
7
|
+
// of the same book (state would otherwise reset on every chapter unmount).
|
|
8
|
+
// State is in-memory only — a hard refresh or navigating to a different book
|
|
9
|
+
// resets it.
|
|
10
|
+
//
|
|
11
|
+
// The flag handler reads `?immersive=1` from the URL (set by the CTA on the
|
|
12
|
+
// book index page) and enters the reader. It's wrapped in <Suspense> on its
|
|
13
|
+
// own so its `useSearchParams` bailout doesn't drag {children} (the chapter
|
|
14
|
+
// page) out of static prerender.
|
|
15
|
+
export default function BookSlugLayout({ children }: { children: ReactNode }) {
|
|
16
|
+
return (
|
|
17
|
+
<ImmersiveReadingProvider>
|
|
18
|
+
<Suspense fallback={null}>
|
|
19
|
+
<ImmersiveReadingFlagHandler />
|
|
20
|
+
</Suspense>
|
|
21
|
+
{children}
|
|
22
|
+
</ImmersiveReadingProvider>
|
|
23
|
+
);
|
|
24
|
+
}
|
|
@@ -159,9 +159,9 @@ export default async function BookLandingPage({ params }: { params: Promise<{ sl
|
|
|
159
159
|
</p>
|
|
160
160
|
)}
|
|
161
161
|
|
|
162
|
-
{/* Start Reading
|
|
162
|
+
{/* Start Reading CTAs */}
|
|
163
163
|
{firstChapter && (
|
|
164
|
-
<div className="mt-8">
|
|
164
|
+
<div className="mt-8 flex flex-wrap items-center justify-center gap-3">
|
|
165
165
|
<Link
|
|
166
166
|
href={getBookChapterUrl(book.slug, firstChapter.id)}
|
|
167
167
|
className="inline-flex items-center gap-2 px-6 py-3 bg-accent text-white rounded-xl font-sans font-medium text-sm hover:bg-accent/90 no-underline transition-colors shadow-lg shadow-accent/20"
|
|
@@ -171,6 +171,22 @@ export default async function BookLandingPage({ params }: { params: Promise<{ sl
|
|
|
171
171
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
|
172
172
|
</svg>
|
|
173
173
|
</Link>
|
|
174
|
+
{/* Secondary CTA — opens the first chapter in immersive mode.
|
|
175
|
+
The `?immersive=1` query param is read by ImmersiveReadingProvider
|
|
176
|
+
on mount, which calls enter() then strips the flag from the URL
|
|
177
|
+
so back-navigation doesn't re-trigger it. */}
|
|
178
|
+
<Link
|
|
179
|
+
href={`${getBookChapterUrl(book.slug, firstChapter.id)}?immersive=1`}
|
|
180
|
+
className="inline-flex items-center gap-2 px-5 py-3 border border-muted/30 text-foreground/80 hover:text-accent hover:border-accent/50 rounded-xl font-sans font-medium text-sm no-underline transition-colors"
|
|
181
|
+
>
|
|
182
|
+
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
|
183
|
+
<path d="M3 7V5a2 2 0 0 1 2-2h2" />
|
|
184
|
+
<path d="M17 3h2a2 2 0 0 1 2 2v2" />
|
|
185
|
+
<path d="M21 17v2a2 2 0 0 1-2 2h-2" />
|
|
186
|
+
<path d="M7 21H5a2 2 0 0 1-2-2v-2" />
|
|
187
|
+
</svg>
|
|
188
|
+
{t('immersive_reading')}
|
|
189
|
+
</Link>
|
|
174
190
|
</div>
|
|
175
191
|
)}
|
|
176
192
|
</div>
|