@hutusi/amytis 1.13.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.
Files changed (91) hide show
  1. package/.github/workflows/ci.yml +1 -1
  2. package/.github/workflows/publish.yml +2 -2
  3. package/CHANGELOG.md +32 -0
  4. package/GEMINI.md +9 -1
  5. package/README.md +36 -2
  6. package/README.zh.md +36 -2
  7. package/TODO.md +10 -0
  8. package/bun.lock +123 -91
  9. package/content/flows/2026/03/05.md +1 -0
  10. package/content/flows/2026/03/07.md +2 -0
  11. package/content/series/modern-web-dev/index.mdx +4 -2
  12. package/content/series/rst-legacy/deeper-notes/images/test.svg +4 -0
  13. package/content/series/rst-legacy/deeper-notes/index.rst +15 -0
  14. package/content/series/rst-legacy/getting-started.rst +24 -0
  15. package/content/series/rst-legacy/index.rst +9 -0
  16. package/content/series/rst-readme/README.rst +9 -0
  17. package/content/series/rst-readme/readme-index-post.rst +10 -0
  18. package/content/series/rst-toctree/first-post.rst +6 -0
  19. package/content/series/rst-toctree/index.rst +10 -0
  20. package/content/series/rst-toctree/second-post.rst +6 -0
  21. package/content/series/rst-toctree-precedence/first-post.rst +6 -0
  22. package/content/series/rst-toctree-precedence/index.rst +12 -0
  23. package/content/series/rst-toctree-precedence/second-post.rst +6 -0
  24. package/docs/ARCHITECTURE.md +30 -4
  25. package/docs/CONTRIBUTING.md +11 -0
  26. package/docs/DIGITAL_GARDEN.md +22 -1
  27. package/eslint.config.mjs +2 -0
  28. package/next.config.ts +2 -2
  29. package/package.json +27 -21
  30. package/packages/create-amytis/package.json +1 -1
  31. package/packages/create-amytis/src/index.test.ts +43 -1
  32. package/packages/create-amytis/src/index.ts +64 -8
  33. package/public/next-image-export-optimizer-hashes.json +14 -73
  34. package/scripts/build-pagefind.ts +172 -0
  35. package/scripts/copy-assets.ts +246 -56
  36. package/scripts/generate-knowledge-graph.ts +2 -1
  37. package/scripts/new-flow.ts +1 -0
  38. package/scripts/render-rst.py +719 -0
  39. package/scripts/run-with-rst-python.ts +42 -0
  40. package/src/app/[slug]/[postSlug]/page.tsx +20 -10
  41. package/src/app/[slug]/page/[page]/page.tsx +15 -0
  42. package/src/app/all.atom/route.ts +7 -0
  43. package/src/app/all.xml/route.ts +7 -0
  44. package/src/app/archive/page.tsx +7 -4
  45. package/src/app/feed.atom/route.ts +2 -57
  46. package/src/app/feed.xml/route.ts +2 -64
  47. package/src/app/flows/[year]/[month]/[day]/page.tsx +13 -0
  48. package/src/app/flows/feed.atom/route.ts +7 -0
  49. package/src/app/flows/feed.xml/route.ts +7 -0
  50. package/src/app/globals.css +165 -0
  51. package/src/app/page.tsx +1 -0
  52. package/src/app/posts/feed.atom/route.ts +9 -0
  53. package/src/app/posts/feed.xml/route.ts +9 -0
  54. package/src/app/series/[slug]/page/[page]/page.tsx +74 -6
  55. package/src/app/series/[slug]/page.tsx +11 -13
  56. package/src/app/series/page.tsx +3 -3
  57. package/src/components/AuthorCard.tsx +25 -16
  58. package/src/components/CoverImage.tsx +5 -2
  59. package/src/components/FlowCalendarSidebar.tsx +1 -1
  60. package/src/components/FlowContent.tsx +2 -1
  61. package/src/components/FlowTimelineEntry.tsx +7 -1
  62. package/src/components/Footer.tsx +1 -1
  63. package/src/components/MarkdownRenderer.test.tsx +22 -0
  64. package/src/components/MarkdownRenderer.tsx +22 -17
  65. package/src/components/Navbar.tsx +1 -1
  66. package/src/components/PostSidebar.tsx +1 -1
  67. package/src/components/RecentNotesSection.tsx +4 -0
  68. package/src/components/RstRenderer.test.tsx +93 -0
  69. package/src/components/RstRenderer.tsx +122 -0
  70. package/src/layouts/PostLayout.tsx +5 -1
  71. package/src/layouts/SimpleLayout.tsx +10 -3
  72. package/src/lib/feed-utils.ts +158 -18
  73. package/src/lib/image-utils.test.ts +19 -0
  74. package/src/lib/image-utils.ts +11 -0
  75. package/src/lib/markdown.test.ts +140 -2
  76. package/src/lib/markdown.ts +747 -214
  77. package/src/lib/rehype-image-metadata.ts +2 -2
  78. package/src/lib/rst-renderer.test.ts +355 -0
  79. package/src/lib/rst-renderer.ts +617 -0
  80. package/src/lib/rst.test.ts +140 -0
  81. package/src/lib/rst.ts +470 -0
  82. package/src/lib/series-redirects.ts +42 -0
  83. package/tests/e2e/navigation.test.ts +26 -0
  84. package/tests/integration/collections.test.ts +17 -2
  85. package/tests/integration/feed-utils.test.ts +65 -0
  86. package/tests/integration/flow-title.test.ts +53 -0
  87. package/tests/integration/reading-time-headings.test.ts +5 -9
  88. package/tests/integration/series-draft.test.ts +16 -2
  89. package/tests/integration/series.test.ts +93 -0
  90. package/tests/tooling/build-pagefind.test.ts +66 -0
  91. package/tests/unit/static-params.test.ts +140 -0
@@ -1,75 +1,16 @@
1
1
  {
2
- "books/agentic-design-patterns/images/appendix-a/image1.png": "k8G1J92r2m-rIbN+EDXjtOtXL3Im9pfIF9E+N4gR6+o=",
3
- "books/agentic-design-patterns/images/appendix-b/image1.png": "j7jY3StmUJFaDcCc37qmkMNApRPw-hqtxo6xGMEvvSI=",
4
- "books/agentic-design-patterns/images/appendix-d/image1.png": "Jz-nZ-kZPaM1XYPAzWY8jJRrmZHH3TOZKMVX6hD-G0I=",
5
- "books/agentic-design-patterns/images/appendix-d/image2.png": "flQCnHTEIqAftdXjvY3gwc+OYbcZDaqwr3rtxDU7PjE=",
6
- "books/agentic-design-patterns/images/appendix-d/image3.png": "FPPSaVvnYJhlRplp25grIwI0+vmtc9NHl+6EcpXIAic=",
7
- "books/agentic-design-patterns/images/appendix-d/image4.png": "TvxBNTKaZCho+W8eTm67fiYmSwDJzwj1AOOguIV+o04=",
8
- "books/agentic-design-patterns/images/appendix-d/image5.png": "EPlVRP7VlPQqkF9fawSV43kQg7JKVsjy-dHLFH6LIBY=",
9
- "books/agentic-design-patterns/images/appendix-d/image6.png": "s7zF5TgNEQfvkakIHhdHXQ7S5imi4+AOtaD+PG7w0xM=",
10
- "books/agentic-design-patterns/images/appendix-g/image1.png": "r-KvQvaqGSwZrN+30Dg6KZx4dEUzKZwPJ+eezw-KH70=",
11
- "books/agentic-design-patterns/images/chapter-1/image1.png": "yfUwbocNorvSCdvdxTqQZmmiZsoRWbsn9iby6iKJYFQ=",
12
- "books/agentic-design-patterns/images/chapter-1/image2.png": "pisZVOmTdn1Y+qs7sAqTqn04BeR3UngCd17kApWgehk=",
13
- "books/agentic-design-patterns/images/chapter-10/image1.png": "aKJwlDxhZkAglLFTGgT8qpjIAlNQ5gV5ovCAnBjwx7M=",
14
- "books/agentic-design-patterns/images/chapter-11/image1.png": "n9ou0BpTuPt3kcTEnCJEQRnMKFd9lI666DBxWDJzd3A=",
15
- "books/agentic-design-patterns/images/chapter-11/image2.png": "30uw9sGMGnE+jyt++vVURBUQNXe5ty+8i8h+PMw4GfM=",
16
- "books/agentic-design-patterns/images/chapter-12/image1.png": "4e4AD2MJFSB3GByGxGVr4VCQ4H+eYkR3oq39KuZhzw8=",
17
- "books/agentic-design-patterns/images/chapter-12/image2.png": "1MyjXYovLBeKJmUGnUnx38T-UgkhRfV5opzOcP+OEdw=",
18
- "books/agentic-design-patterns/images/chapter-13/image1.png": "PB3msIGhOmq1Oyrv6ubGJZJCvO+dD7XNxZiw5Ck-834=",
19
- "books/agentic-design-patterns/images/chapter-14/image1.png": "p-06-D2Vp2x7HMXwmkTNLneS9sjsfSvmTsLTlVUjY5k=",
20
- "books/agentic-design-patterns/images/chapter-14/image2.png": "XvXKYbuijYChfq4+MlZYXLBpxTg11ddJ0AFnzvntLPw=",
21
- "books/agentic-design-patterns/images/chapter-14/image3.png": "YhmOWyJo3pDKVbKW7RMgTbNrbmsE89RkmaJ2z76vhos=",
22
- "books/agentic-design-patterns/images/chapter-14/image4.png": "QBNe+Wt65qdLfkC5MJqaiNBJGMa-DbCm3gCOqfB7ehk=",
23
- "books/agentic-design-patterns/images/chapter-15/image1.png": "I90CSqCb8g-h1F0a3sDusDC+LmHdjJTBBk6iFw4ZnJY=",
24
- "books/agentic-design-patterns/images/chapter-15/image2.png": "HaxOSZ1f67BhEPa3KXtYAlK0cNm3G4H24dnLXpcgyA0=",
25
- "books/agentic-design-patterns/images/chapter-16/image1.png": "Blc0vDhlie6PM3f6QSUX3argbfezTNDOLYhmT9uuS6E=",
26
- "books/agentic-design-patterns/images/chapter-16/image2.png": "9-KZ0VOekDwFVL1dEzB9aleyCYF+WU6bx1h3aVNoOAM=",
27
- "books/agentic-design-patterns/images/chapter-17/image1.png": "jjGM2Ll5bdszl9xFThVxxJzcq76iYDM1zz8qq2MVVYE=",
28
- "books/agentic-design-patterns/images/chapter-17/image2.png": "O1KLKrodQew2Pdd7k3vBZhE4dCiK9u6JLvXSXy7h1do=",
29
- "books/agentic-design-patterns/images/chapter-17/image3.png": "Sd9qFn-U5g8dhinNPJEk5LaeaN8DFH2FCus1Wcm6oSg=",
30
- "books/agentic-design-patterns/images/chapter-17/image4.png": "eV38Dg78Y2Qo-0sqlsvcK8-p19fgRJqwx42sBKTzC+s=",
31
- "books/agentic-design-patterns/images/chapter-17/image5.png": "bgKPC1TSVlvfkCG5vILT-4j9gJU7LyeGFkXO+8OFdXE=",
32
- "books/agentic-design-patterns/images/chapter-17/image6.png": "qMku-C8gxZEFQEl8Qq8Y1ld5zUntH1Q-2izcb4QxLA0=",
33
- "books/agentic-design-patterns/images/chapter-17/image7.png": "20wopLVN59okB+C-F+U5vi79Lg0uH+5P6wIj72gV7wU=",
34
- "books/agentic-design-patterns/images/chapter-18/image1.png": "kjCIoNU60icXMOHmYNXWNMjdi5nIdJZYDSp0WFnG2Es=",
35
- "books/agentic-design-patterns/images/chapter-19/image1.png": "XrQVFAWWsMcwKHgcqx8jGLy4q+6RbiGZ5CuKpRlk4Y0=",
36
- "books/agentic-design-patterns/images/chapter-19/image2.png": "URHY+cH5AR4KvA6HPnXPhZd5fwwGdlwvFvxgCdTkWjg=",
37
- "books/agentic-design-patterns/images/chapter-19/image3.png": "VLp1qFNcaj126-RuATV6ikSsow8lrXsn3EuZotvR5N4=",
38
- "books/agentic-design-patterns/images/chapter-19/image4.png": "CnyNMtPiFSdzqrewwLgPu2X1CKdgMqCl8T8+d7lw2Fw=",
39
- "books/agentic-design-patterns/images/chapter-2/image1.png": "C6odPDB9zhN28YxfU909JAoQWe0mRm3lUsyG8NzHBWk=",
40
- "books/agentic-design-patterns/images/chapter-20/image1.png": "qkzjKCUjVFD8IWyoZQXuBuZ4zFJ0Y0CQfKepVcrnEN0=",
41
- "books/agentic-design-patterns/images/chapter-21/image1.png": "3KpW6uLQDru7DFDdgi6ntD5WhuGS383aYwlQLWoVX+w=",
42
- "books/agentic-design-patterns/images/chapter-21/image2.png": "wSXMrRuAnhQcEM5fg-sJ5ZikM3olj0vz6FDKWKIUyW8=",
43
- "books/agentic-design-patterns/images/chapter-3/image1.png": "iHnUE2kckwzB0xiIySSe4O34c6bIs3VlO7pUQBmV8iI=",
44
- "books/agentic-design-patterns/images/chapter-3/image2.png": "9-G6F+aBymQVTnDQy4zwsXDZMkLaYKBe4xw26tC24R8=",
45
- "books/agentic-design-patterns/images/chapter-4/image1.png": "8h0PBlqt+JFo7Y803cA9MgfTe8TZeZd6te9l+WEsIss=",
46
- "books/agentic-design-patterns/images/chapter-4/image2.png": "p-arzpUPFdnKmzTOKaDQys5Eml0jHA7VuUV0FyLYpqM=",
47
- "books/agentic-design-patterns/images/chapter-5/image1.png": "XE5qhAklrW-7eVf7T9x9vo4xQX3WesZjZ9IjuyeSUkc=",
48
- "books/agentic-design-patterns/images/chapter-5/image2.png": "MqUdsG5aXQECft+lsCRTeMIJP-YzxWFHC8HdNzio6aw=",
49
- "books/agentic-design-patterns/images/chapter-6/image1.png": "t6mFrbVSNJYF73t+Q+YydXdwRQr1c4yIUkySvCelw1I=",
50
- "books/agentic-design-patterns/images/chapter-6/image2.png": "zvwqWis6Ad7Iv1-wgYtTgz2ItXsno2PetcHRHon0-wY=",
51
- "books/agentic-design-patterns/images/chapter-6/image3.png": "al-H2S7UznxQzP3KT3N0lCTNd83B45bqbPppuhSphy0=",
52
- "books/agentic-design-patterns/images/chapter-6/image4.png": "u2k2jHjTOABLTlJ3OsxkGyCoOR-fwHeDmTXMACbtQ+4=",
53
- "books/agentic-design-patterns/images/chapter-7/image1.png": "izX9YjrJ+gHGOiR9RHbuMIupCE2zyB01w-gwJAJr4Is=",
54
- "books/agentic-design-patterns/images/chapter-7/image2.png": "-0zrZR8CNZqtuyaBRwUbZSfthKwuqrBu4fLar9L4Clg=",
55
- "books/agentic-design-patterns/images/chapter-7/image3.png": "dKr7VICWOTc-ZRhm4Aaokz+xPZ8IXS0SiDOJeF1Y03c=",
56
- "books/agentic-design-patterns/images/chapter-8/image1.png": "GWG47Om+9GwRvl8x-X269Sa-Mv3rqkW2xkx1WIB-I-E=",
57
- "books/agentic-design-patterns/images/chapter-9/image1.png": "fIZ4QE7EIQSAv0K2GmU99OmTBAkpvnYUsFFScCv6VPI=",
58
- "books/agentic-design-patterns/images/chapter-9/image2.png": "6y-8OjY7oEedk5ZhUUU2YIU3tjMxIzpnxcDxIRWPGpI=",
59
- "books/agentic-design-patterns/images/chapter-9/image3.png": "69nSkOOPfX9FkIJGhKIrhSuCOZt29L5UAukVXfMXN2E=",
60
- "books/agentic-design-patterns/images/chapter-9/image4.png": "GQCpjBMz5DRxsHIA8-73IMsxeBPc5ZTMaIsx2iihMME=",
61
- "books/agentic-design-patterns/images/cover.png": "R47dqw-ws79m6VBfzhWuLi9ihoCaU7JcezquBAB1-bQ=",
62
- "images/amytis-screenshot.jpg": "t9tzciqJPsNz1hlixAFLrrvD4Fsaih2Kbd1Jd2C-fqs=",
63
- "images/antelope-canyon.jpg": "xuP3HlbwqA4z1Hsq7s1u2u61yN3SHzIMUd6Wl5yqgK8=",
64
- "images/avatar.jpg": "SUGxOhDUD96YiVxZJkVJ6ou4W5MdCJ9hTBQMSQanZOM=",
65
- "images/cappadocia.jpg": "EE2Mt8d9ERKd40SfwyhjFsejZLhFx1SfSAQmlu+TQOo=",
66
- "images/flowers.jpg": "Lq9dhpZvAIgkYnijDsFIzrKiv8trdM-cUnl-5Y86PuM=",
67
- "images/galaxy.jpg": "-3YwMkU3PGsm4P2yNsA2S02XRL1j0KbBh6nWKwy0hYc=",
68
- "images/lake.jpg": "imAORQhxpmoU3jzKBMNFJuFSa0UgiSf2Dmea5Rj8-8M=",
69
- "images/mountains.jpg": "FsrkZws9EKMqHCk1Hc6i6nEIcTRcrMBa4ddgqR6oRaI=",
70
- "images/screenshot.png": "FAqbAgLRbWbYq9yJ4iggq2aKxRD8hdeDICc3DI14yhg=",
71
- "images/vibrant-waves.jpg": "vdBm72ev5ETPM5H2CDK6tqph5t8N5nTCSApBJp2lW6U=",
72
- "images/wechat-qr.jpg": "DNIzz0Wcl8WP0h-jHHgZ9LEvf3ZKOXHgxlvpw3gK2ME=",
73
- "posts/02-routing-mastery/assets/m-p-model.png": "fDmvlEkZnE-UCvPK4gmDkJD7SU8coOTl4iw5hpsmcWI=",
74
- "posts/images/vibrant-waves.jpg": "YogfFJOm4PQV1g3iMRpCL1pKtjPXpMeJDFf6NTILcBQ="
2
+ "images/amytis-screenshot.jpg": "rWj-Nh3prlVpvj8efQnsSHJTSOOVzgz9PGnQRj98jXc=",
3
+ "images/antelope-canyon.jpg": "sub7CbOTPj1Vred2Ow9yJKMqjbP9+0nkjmmaaa5Di2k=",
4
+ "images/avatar.jpg": "4c7EI6nQ6UHOj9drx0D42heSmiYWTt0Gd3Nsri3BQeo=",
5
+ "images/cappadocia.jpg": "eDzJe+p3qkAP83ATa7x0m0od4LxwkZltWsVQ2s9rgDI=",
6
+ "images/flowers.jpg": "oxwbVQa8o5AcEu-vRbHj9o64BTZwCFxPOSA7D53n7tI=",
7
+ "images/galaxy.jpg": "yDGttkksBSUhLDWlVL2BFGg1al1+GHy3BXcEVp07bys=",
8
+ "images/lake.jpg": "NEaTNMMXdrgHLMU7CDEWEVwguVdv1E4y5FN8CMHqTvg=",
9
+ "images/mountains.jpg": "0YIE1wGkreZ1MzM8zGnXCQ9I-IcmA+zO52-dtfL4X94=",
10
+ "images/screenshot.png": "zuBVQ-P-dCmcM9tYPdDvixwAHo7rGTZQE1y+X4o2pJ4=",
11
+ "images/vibrant-waves.jpg": "uWywQzN1RbusnZFato8mb7pqvCUkAr4us50HU6rFYUg=",
12
+ "images/wechat-qr.jpg": "Vhxz8S8TDfXZUjB3M5o5jvis3hu2n8xJEdW3bPjtd+A=",
13
+ "posts/02-routing-mastery/assets/m-p-model.png": "LZQHFy9SfWdBHatT9DwrARlD1sRqLT2blB573YyTv-U=",
14
+ "posts/images/vibrant-waves.jpg": "J+C7EolOmfP3fHl-22UoIx-h0hImPk9iEhL+66I60P4=",
15
+ "posts/welcome-to-amytis/images/vibrant-waves.jpg": "gAUaQXipxs2k7jJ6OoyIJ1+hj9HjM5QeuUuWTQoByps="
75
16
  }
@@ -0,0 +1,172 @@
1
+ import { createHash } from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import * as pagefind from 'pagefind';
5
+
6
+ interface PagefindManifest {
7
+ version: string;
8
+ sitePath: string;
9
+ outputPath: string;
10
+ files: Record<string, string>;
11
+ }
12
+
13
+ const PAGEFIND_MANIFEST_VERSION = '1';
14
+ const pagefindCacheDir = path.join(process.cwd(), '.cache', 'pagefind');
15
+
16
+ function parseArgs(argv: string[]): { sitePath: string; outputPath: string } {
17
+ let sitePath = 'out';
18
+ let outputPath = path.join('out', 'pagefind');
19
+
20
+ for (let i = 0; i < argv.length; i++) {
21
+ const arg = argv[i];
22
+ if (arg === '--site') {
23
+ sitePath = argv[++i] ?? sitePath;
24
+ continue;
25
+ }
26
+ if (arg === '--output-path') {
27
+ outputPath = argv[++i] ?? outputPath;
28
+ continue;
29
+ }
30
+ }
31
+
32
+ return { sitePath, outputPath };
33
+ }
34
+
35
+ function walkHtmlFiles(rootDir: string): string[] {
36
+ const files: string[] = [];
37
+
38
+ const visit = (dir: string) => {
39
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
40
+ for (const entry of entries) {
41
+ const fullPath = path.join(dir, entry.name);
42
+ if (entry.isDirectory()) {
43
+ visit(fullPath);
44
+ continue;
45
+ }
46
+ if (entry.isFile() && entry.name.endsWith('.html')) {
47
+ files.push(fullPath);
48
+ }
49
+ }
50
+ };
51
+
52
+ if (fs.existsSync(rootDir)) {
53
+ visit(rootDir);
54
+ }
55
+
56
+ return files.sort();
57
+ }
58
+
59
+ export function collectHtmlFileHashes(sitePath: string): Record<string, string> {
60
+ const absoluteSitePath = path.resolve(sitePath);
61
+ const files = walkHtmlFiles(absoluteSitePath);
62
+ const hashes: Record<string, string> = {};
63
+
64
+ for (const filePath of files) {
65
+ const relativePath = path.relative(absoluteSitePath, filePath).replaceAll(path.sep, '/');
66
+ const content = fs.readFileSync(filePath);
67
+ hashes[relativePath] = createHash('sha1').update(content).digest('hex');
68
+ }
69
+
70
+ return hashes;
71
+ }
72
+
73
+ function getPagefindManifestPath(sitePath: string, outputPath: string): string {
74
+ const cacheKey = createHash('sha1')
75
+ .update(`${path.resolve(sitePath)}::${path.resolve(outputPath)}`)
76
+ .digest('hex');
77
+ return path.join(pagefindCacheDir, `${cacheKey}.json`);
78
+ }
79
+
80
+ export function getPagefindManifestPathForTests(sitePath: string, outputPath: string): string {
81
+ return getPagefindManifestPath(sitePath, outputPath);
82
+ }
83
+
84
+ function loadManifest(sitePath: string, outputPath: string): PagefindManifest | null {
85
+ const manifestPath = getPagefindManifestPath(sitePath, outputPath);
86
+ if (!fs.existsSync(manifestPath)) return null;
87
+
88
+ try {
89
+ return JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as PagefindManifest;
90
+ } catch {
91
+ return null;
92
+ }
93
+ }
94
+
95
+ function writeManifest(sitePath: string, outputPath: string, files: Record<string, string>): void {
96
+ const manifestPath = getPagefindManifestPath(sitePath, outputPath);
97
+ const manifest: PagefindManifest = {
98
+ version: PAGEFIND_MANIFEST_VERSION,
99
+ sitePath: path.resolve(sitePath),
100
+ outputPath: path.resolve(outputPath),
101
+ files,
102
+ };
103
+
104
+ try {
105
+ fs.mkdirSync(path.dirname(manifestPath), { recursive: true });
106
+ fs.writeFileSync(manifestPath, JSON.stringify(manifest), 'utf8');
107
+ } catch (error) {
108
+ console.warn(`[pagefind] Failed to persist manifest at ${manifestPath}:`, error);
109
+ }
110
+ }
111
+
112
+ export function shouldSkipPagefindBuild(sitePath: string, outputPath: string, currentFiles: Record<string, string>): boolean {
113
+ const manifest = loadManifest(sitePath, outputPath);
114
+ if (!manifest) return false;
115
+ if (manifest.version !== PAGEFIND_MANIFEST_VERSION) return false;
116
+ if (manifest.sitePath !== path.resolve(sitePath)) return false;
117
+ if (manifest.outputPath !== path.resolve(outputPath)) return false;
118
+ if (!fs.existsSync(outputPath)) return false;
119
+
120
+ const previousEntries = Object.entries(manifest.files).sort(([a], [b]) => a.localeCompare(b));
121
+ const currentEntries = Object.entries(currentFiles).sort(([a], [b]) => a.localeCompare(b));
122
+ if (previousEntries.length !== currentEntries.length) return false;
123
+
124
+ return previousEntries.every(([file, hash], index) => {
125
+ const [currentFile, currentHash] = currentEntries[index];
126
+ return file === currentFile && hash === currentHash;
127
+ });
128
+ }
129
+
130
+ async function buildPagefind(sitePath: string, outputPath: string): Promise<void> {
131
+ const currentFiles = collectHtmlFileHashes(sitePath);
132
+
133
+ if (shouldSkipPagefindBuild(sitePath, outputPath, currentFiles)) {
134
+ console.log(`[pagefind] Skipping rebuild; no HTML changes detected for ${sitePath}.`);
135
+ return;
136
+ }
137
+
138
+ fs.rmSync(outputPath, { recursive: true, force: true });
139
+
140
+ const { index, errors } = await pagefind.createIndex();
141
+ if (errors.length > 0 || !index) {
142
+ throw new Error(`Failed to create Pagefind index: ${errors.join(', ') || 'unknown error'}`);
143
+ }
144
+
145
+ try {
146
+ const addResult = await index.addDirectory({ path: sitePath });
147
+ if (addResult.errors.length > 0) {
148
+ throw new Error(`Failed to index ${sitePath}: ${addResult.errors.join(', ')}`);
149
+ }
150
+
151
+ const writeResult = await index.writeFiles({ outputPath });
152
+ if (writeResult.errors.length > 0) {
153
+ throw new Error(`Failed to write Pagefind output: ${writeResult.errors.join(', ')}`);
154
+ }
155
+ } finally {
156
+ await pagefind.close();
157
+ }
158
+
159
+ writeManifest(sitePath, outputPath, currentFiles);
160
+ }
161
+
162
+ async function main() {
163
+ const { sitePath, outputPath } = parseArgs(Bun.argv.slice(2));
164
+ await buildPagefind(sitePath, outputPath);
165
+ }
166
+
167
+ if (import.meta.main) {
168
+ main().catch((error) => {
169
+ console.error('[pagefind] Build failed:', error);
170
+ process.exit(1);
171
+ });
172
+ }
@@ -9,45 +9,114 @@ const flowsSrcDir = path.join(process.cwd(), 'content', 'flows');
9
9
  const destDir = path.join(process.cwd(), 'public', 'posts');
10
10
  const booksDestDir = path.join(process.cwd(), 'public', 'books');
11
11
  const flowsDestDir = path.join(process.cwd(), 'public', 'flows');
12
+ const optimizerDirName = 'nextImageExportOptimizer';
13
+ const generatedAssetDestinations = new Set<string>();
14
+
15
+ function copyFileOrThrow(srcPath: string, destPath: string, context: string) {
16
+ try {
17
+ fs.copyFileSync(srcPath, destPath);
18
+ } catch (error) {
19
+ const message = error instanceof Error ? error.message : String(error);
20
+ throw new Error(`[copy-assets] Failed to copy ${context}: ${srcPath} -> ${destPath}: ${message}`);
21
+ }
22
+ }
23
+
24
+ function markGeneratedDestination(destPath: string) {
25
+ generatedAssetDestinations.add(path.resolve(destPath));
26
+ }
27
+
28
+ function shouldPreserveOptimizerDir(optimizerPath: string): boolean {
29
+ const optimizerParentPath = path.resolve(path.dirname(optimizerPath));
30
+ return [...generatedAssetDestinations].some((generatedDestination) =>
31
+ optimizerParentPath === generatedDestination ||
32
+ optimizerParentPath.startsWith(`${generatedDestination}${path.sep}`)
33
+ );
34
+ }
35
+
36
+ function pruneOrphanedOptimizerDirs(rootDir: string) {
37
+ if (!fs.existsSync(rootDir)) return;
38
+
39
+ const entries = fs.readdirSync(rootDir, { withFileTypes: true });
40
+ for (const entry of entries) {
41
+ const entryPath = path.join(rootDir, entry.name);
42
+
43
+ if (!entry.isDirectory()) {
44
+ continue;
45
+ }
46
+
47
+ if (entry.name === optimizerDirName) {
48
+ if (!shouldPreserveOptimizerDir(entryPath)) {
49
+ fs.rmSync(entryPath, { recursive: true, force: true });
50
+ }
51
+ continue;
52
+ }
53
+
54
+ pruneOrphanedOptimizerDirs(entryPath);
55
+ }
56
+ }
57
+
58
+ function resetGeneratedAssetDirs() {
59
+ generatedAssetDestinations.clear();
60
+ fs.mkdirSync(destDir, { recursive: true });
61
+ fs.mkdirSync(booksDestDir, { recursive: true });
62
+ fs.mkdirSync(flowsDestDir, { recursive: true });
63
+ }
64
+
65
+ function shouldSkipSourceFile(name: string): boolean {
66
+ return name.endsWith('.md') || name.endsWith('.mdx') || name.endsWith('.rst');
67
+ }
12
68
 
13
- function copyRecursive(src: string, dest: string) {
69
+ function shouldCopyBasedOnMtimeAndSize(srcPath: string, destPath: string): boolean {
70
+ if (!fs.existsSync(destPath)) {
71
+ return true;
72
+ }
73
+
74
+ const srcStat = fs.statSync(srcPath);
75
+ const destStat = fs.statSync(destPath);
76
+ return srcStat.mtimeMs > destStat.mtimeMs || srcStat.size !== destStat.size;
77
+ }
78
+
79
+ function syncRecursive(src: string, dest: string) {
14
80
  if (!fs.existsSync(src)) return;
15
-
81
+
16
82
  if (!fs.existsSync(dest)) {
17
83
  fs.mkdirSync(dest, { recursive: true });
18
84
  }
19
85
 
20
- const entries = fs.readdirSync(src, { withFileTypes: true });
86
+ const srcEntries = fs.readdirSync(src, { withFileTypes: true });
87
+ const srcNames = new Set(srcEntries.map((entry) => entry.name));
21
88
 
22
- for (const entry of entries) {
89
+ for (const entry of srcEntries) {
23
90
  const srcPath = path.join(src, entry.name);
24
91
  const destPath = path.join(dest, entry.name);
25
92
 
26
93
  if (entry.isDirectory()) {
27
- copyRecursive(srcPath, destPath);
94
+ syncRecursive(srcPath, destPath);
28
95
  } else {
29
- // Copy all files except markdown source
30
- if (!entry.name.endsWith('.md') && !entry.name.endsWith('.mdx')) {
31
- let shouldCopy = true;
32
- if (fs.existsSync(destPath)) {
33
- const srcStat = fs.statSync(srcPath);
34
- const destStat = fs.statSync(destPath);
35
- if (srcStat.mtimeMs <= destStat.mtimeMs) {
36
- shouldCopy = false;
37
- }
38
- }
96
+ if (shouldSkipSourceFile(entry.name)) {
97
+ continue;
98
+ }
39
99
 
40
- if (shouldCopy) {
41
- fs.copyFileSync(srcPath, destPath);
42
- // console.log(`Copied: ${entry.name} -> ${destPath}`);
43
- }
100
+ if (shouldCopyBasedOnMtimeAndSize(srcPath, destPath)) {
101
+ copyFileOrThrow(srcPath, destPath, 'recursive asset');
44
102
  }
45
103
  }
46
104
  }
105
+
106
+ const destEntries = fs.readdirSync(dest, { withFileTypes: true });
107
+ for (const entry of destEntries) {
108
+ if (entry.name === optimizerDirName) continue;
109
+
110
+ const destPath = path.join(dest, entry.name);
111
+
112
+ if (!srcNames.has(entry.name)) {
113
+ fs.rmSync(destPath, { recursive: true, force: true });
114
+ }
115
+ }
47
116
  }
48
117
 
49
118
  function getSlugFromFilename(filename: string): string {
50
- const nameWithoutExt = filename.replace(/\.mdx?$/, '');
119
+ const nameWithoutExt = filename.replace(/\.(mdx?|rst)$/, '');
51
120
  const dateRegex = /^(\d{4}-\d{2}-\d{2})-(.*)$/;
52
121
  const match = nameWithoutExt.match(dateRegex);
53
122
 
@@ -57,6 +126,124 @@ function getSlugFromFilename(filename: string): string {
57
126
  return nameWithoutExt;
58
127
  }
59
128
 
129
+ function isLocalAssetReference(rawPath: string): boolean {
130
+ const trimmed = rawPath.trim();
131
+ return Boolean(trimmed) &&
132
+ !trimmed.startsWith('#') &&
133
+ !trimmed.startsWith('/') &&
134
+ !trimmed.startsWith('http://') &&
135
+ !trimmed.startsWith('https://') &&
136
+ !trimmed.startsWith('data:') &&
137
+ !trimmed.startsWith('mailto:') &&
138
+ !trimmed.startsWith('javascript:');
139
+ }
140
+
141
+ function normalizeReferencedAssetPath(rawPath: string): string | null {
142
+ const trimmed = rawPath.trim().replace(/^['"]|['"]$/g, '');
143
+ const withoutFragment = trimmed.split('#')[0]?.split('?')[0]?.trim();
144
+ if (!withoutFragment || !isLocalAssetReference(withoutFragment)) {
145
+ return null;
146
+ }
147
+
148
+ return withoutFragment;
149
+ }
150
+
151
+ function extractReferencedAssetPaths(filePath: string): string[] {
152
+ const content = fs.readFileSync(filePath, 'utf-8');
153
+ const references = new Set<string>();
154
+ const patterns = [
155
+ /\!\[[^\]]*\]\(([^)]+)\)/g,
156
+ /\[[^\]]*\]\(([^)]+)\)/g,
157
+ /\b(?:src|href|poster)=["']([^"']+)["']/g,
158
+ /^\s*\.\.\s+(?:image|figure)::\s+(.+)$/gm,
159
+ /^coverImage:\s*['"]?([^'"\n]+)['"]?$/gm,
160
+ ];
161
+
162
+ for (const pattern of patterns) {
163
+ for (const match of content.matchAll(pattern)) {
164
+ const candidate = normalizeReferencedAssetPath(match[1] ?? '');
165
+ if (candidate) {
166
+ references.add(candidate);
167
+ }
168
+ }
169
+ }
170
+
171
+ return [...references];
172
+ }
173
+
174
+ function syncReferencedAssets(sourceFile: string, rootDir: string, destPostDir: string) {
175
+ const references = extractReferencedAssetPaths(sourceFile);
176
+ const desiredRelativePaths = new Set<string>();
177
+
178
+ if (!fs.existsSync(destPostDir)) {
179
+ fs.mkdirSync(destPostDir, { recursive: true });
180
+ }
181
+
182
+ references.forEach((reference) => {
183
+ const absolutePath = path.resolve(path.dirname(sourceFile), reference);
184
+ const relativeToRoot = path.relative(rootDir, absolutePath);
185
+
186
+ if (relativeToRoot.startsWith('..') || path.isAbsolute(relativeToRoot) || !fs.existsSync(absolutePath)) {
187
+ return;
188
+ }
189
+
190
+ const sourceStat = fs.statSync(absolutePath);
191
+ if (sourceStat.isDirectory()) {
192
+ return;
193
+ }
194
+
195
+ if (absolutePath.endsWith('.md') || absolutePath.endsWith('.mdx') || absolutePath.endsWith('.rst')) {
196
+ return;
197
+ }
198
+
199
+ const destPath = path.join(destPostDir, relativeToRoot);
200
+ desiredRelativePaths.add(relativeToRoot.replaceAll(path.sep, '/'));
201
+ if (!fs.existsSync(path.dirname(destPath))) {
202
+ fs.mkdirSync(path.dirname(destPath), { recursive: true });
203
+ }
204
+
205
+ if (shouldCopyBasedOnMtimeAndSize(absolutePath, destPath)) {
206
+ copyFileOrThrow(absolutePath, destPath, `referenced asset from ${sourceFile}`);
207
+ }
208
+ });
209
+
210
+ function pruneDestDir(currentDir: string, relativeDir = '') {
211
+ if (!fs.existsSync(currentDir)) return;
212
+
213
+ const entries = fs.readdirSync(currentDir, { withFileTypes: true });
214
+ for (const entry of entries) {
215
+ if (entry.name === optimizerDirName) continue;
216
+
217
+ const relativePath = relativeDir ? path.join(relativeDir, entry.name) : entry.name;
218
+ const normalizedRelativePath = relativePath.replaceAll(path.sep, '/');
219
+ const fullPath = path.join(currentDir, entry.name);
220
+
221
+ if (entry.isDirectory()) {
222
+ const hasDesiredDescendant = [...desiredRelativePaths].some((desiredPath) =>
223
+ desiredPath === normalizedRelativePath || desiredPath.startsWith(`${normalizedRelativePath}/`)
224
+ );
225
+
226
+ if (!hasDesiredDescendant) {
227
+ fs.rmSync(fullPath, { recursive: true, force: true });
228
+ continue;
229
+ }
230
+
231
+ pruneDestDir(fullPath, relativePath);
232
+ if (fs.existsSync(fullPath) && fs.readdirSync(fullPath).length === 0) {
233
+ fs.rmdirSync(fullPath);
234
+ }
235
+ continue;
236
+ }
237
+
238
+ if (!desiredRelativePaths.has(normalizedRelativePath)) {
239
+ fs.rmSync(fullPath, { force: true });
240
+ }
241
+ }
242
+ }
243
+
244
+ pruneDestDir(destPostDir);
245
+ }
246
+
60
247
  function processPosts() {
61
248
  if (fs.existsSync(srcDir)) {
62
249
  const entries = fs.readdirSync(srcDir, { withFileTypes: true });
@@ -68,7 +255,19 @@ function processPosts() {
68
255
  const destPostDir = path.join(destDir, targetName);
69
256
 
70
257
  console.log(`Processing Post: ${entry.name} -> ${targetName}`);
71
- copyRecursive(srcPostDir, destPostDir);
258
+ markGeneratedDestination(destPostDir);
259
+ syncRecursive(srcPostDir, destPostDir);
260
+ } else if (entry.isFile() && (entry.name.endsWith('.md') || entry.name.endsWith('.mdx') || entry.name.endsWith('.rst'))) {
261
+ const targetName = getSlugFromFilename(entry.name);
262
+ const sourceFile = path.join(srcDir, entry.name);
263
+ const destPostDir = path.join(destDir, targetName);
264
+
265
+ console.log(`Processing Flat Post: ${entry.name} -> ${targetName}`);
266
+ markGeneratedDestination(destPostDir);
267
+ if (!fs.existsSync(destPostDir)) {
268
+ fs.mkdirSync(destPostDir, { recursive: true });
269
+ }
270
+ syncReferencedAssets(sourceFile, srcDir, destPostDir);
72
271
  }
73
272
  });
74
273
  }
@@ -77,12 +276,8 @@ function processPosts() {
77
276
  // Check if a directory is a post folder (contains index.md or index.mdx)
78
277
  function isPostFolder(dirPath: string): boolean {
79
278
  return fs.existsSync(path.join(dirPath, 'index.md')) ||
80
- fs.existsSync(path.join(dirPath, 'index.mdx'));
81
- }
82
-
83
- // Check if a directory is an asset folder (not a post folder)
84
- function isAssetFolder(dirPath: string): boolean {
85
- return !isPostFolder(dirPath);
279
+ fs.existsSync(path.join(dirPath, 'index.mdx')) ||
280
+ fs.existsSync(path.join(dirPath, 'index.rst'));
86
281
  }
87
282
 
88
283
  function processSeries() {
@@ -95,37 +290,23 @@ function processSeries() {
95
290
  const seriesPath = path.join(seriesSrcDir, seriesEntry.name);
96
291
  const items = fs.readdirSync(seriesPath, { withFileTypes: true });
97
292
 
98
- // First pass: identify shared asset folders at series level (folders that are NOT posts)
99
- const sharedAssetFolders = items
100
- .filter(item => item.isDirectory() && isAssetFolder(path.join(seriesPath, item.name)))
101
- .map(item => item.name);
102
-
103
293
  // Process items in series folder
104
294
  items.forEach(item => {
105
- if (item.isFile() && (item.name.endsWith('.md') || item.name.endsWith('.mdx'))) {
295
+ if (item.isFile() && (item.name.endsWith('.md') || item.name.endsWith('.mdx') || item.name.endsWith('.rst'))) {
106
296
  // File-based post or series index
107
- const targetSlug = item.name.startsWith('index.') ? seriesEntry.name : getSlugFromFilename(item.name);
297
+ const isSeriesIndex = item.name.startsWith('index.') || item.name.startsWith('README.');
298
+ const targetSlug = isSeriesIndex ? seriesEntry.name : getSlugFromFilename(item.name);
299
+ const sourceFile = path.join(seriesPath, item.name);
108
300
  const destPostDir = path.join(destDir, targetSlug);
109
301
 
110
302
  console.log(`Processing Series File: ${item.name} -> ${targetSlug}`);
303
+ markGeneratedDestination(destPostDir);
111
304
 
112
- // Only copy shared asset folders (like images/, assets/), not sibling post folders
113
- sharedAssetFolders.forEach(folderName => {
114
- const srcPath = path.join(seriesPath, folderName);
115
- const destPath = path.join(destPostDir, folderName);
116
- copyRecursive(srcPath, destPath);
117
- });
305
+ if (!fs.existsSync(destPostDir)) {
306
+ fs.mkdirSync(destPostDir, { recursive: true });
307
+ }
118
308
 
119
- // Copy any non-markdown files at the series root level
120
- items.filter(sub => sub.isFile() && !sub.name.endsWith('.md') && !sub.name.endsWith('.mdx'))
121
- .forEach(sub => {
122
- const srcPath = path.join(seriesPath, sub.name);
123
- const destPath = path.join(destPostDir, sub.name);
124
- if (!fs.existsSync(destPostDir)) {
125
- fs.mkdirSync(destPostDir, { recursive: true });
126
- }
127
- fs.copyFileSync(srcPath, destPath);
128
- });
309
+ syncReferencedAssets(sourceFile, seriesPath, destPostDir);
129
310
 
130
311
  } else if (item.isDirectory() && isPostFolder(path.join(seriesPath, item.name))) {
131
312
  // Folder-based post: copy only its own assets
@@ -134,6 +315,7 @@ function processSeries() {
134
315
  const destPostDir = path.join(destDir, targetSlug);
135
316
 
136
317
  console.log(`Processing Series Post Folder: ${item.name} -> ${targetSlug}`);
318
+ markGeneratedDestination(destPostDir);
137
319
 
138
320
  // Copy everything from the post folder EXCEPT markdown files
139
321
  const subItems = fs.readdirSync(itemSrcPath, { withFileTypes: true });
@@ -142,12 +324,14 @@ function processSeries() {
142
324
  const destPath = path.join(destPostDir, sub.name);
143
325
 
144
326
  if (sub.isDirectory()) {
145
- copyRecursive(srcPath, destPath);
146
- } else if (!sub.name.endsWith('.md') && !sub.name.endsWith('.mdx')) {
327
+ syncRecursive(srcPath, destPath);
328
+ } else if (!shouldSkipSourceFile(sub.name)) {
147
329
  if (!fs.existsSync(destPostDir)) {
148
330
  fs.mkdirSync(destPostDir, { recursive: true });
149
331
  }
150
- fs.copyFileSync(srcPath, destPath);
332
+ if (shouldCopyBasedOnMtimeAndSize(srcPath, destPath)) {
333
+ copyFileOrThrow(srcPath, destPath, `series post asset from ${itemSrcPath}`);
334
+ }
151
335
  }
152
336
  });
153
337
  }
@@ -167,7 +351,8 @@ function processBooks() {
167
351
  const destBookDir = path.join(booksDestDir, entry.name);
168
352
 
169
353
  console.log(`Processing Book: ${entry.name}`);
170
- copyRecursive(srcBookDir, destBookDir);
354
+ markGeneratedDestination(destBookDir);
355
+ syncRecursive(srcBookDir, destBookDir);
171
356
  }
172
357
  });
173
358
  }
@@ -197,15 +382,20 @@ function processFlows() {
197
382
  const destFlowDir = path.join(flowsDestDir, yearEntry.name, monthEntry.name, rawName);
198
383
 
199
384
  console.log(`Processing Flow: ${yearEntry.name}/${monthEntry.name}/${rawName}`);
200
- copyRecursive(srcFlowDir, destFlowDir);
385
+ markGeneratedDestination(destFlowDir);
386
+ syncRecursive(srcFlowDir, destFlowDir);
201
387
  }
202
388
  }
203
389
  }
204
390
  }
205
391
 
206
392
  console.log('Copying assets...');
393
+ resetGeneratedAssetDirs();
207
394
  processPosts();
208
395
  processSeries();
209
396
  processBooks();
210
397
  processFlows();
211
- console.log('Assets copied successfully.');
398
+ pruneOrphanedOptimizerDirs(destDir);
399
+ pruneOrphanedOptimizerDirs(booksDestDir);
400
+ pruneOrphanedOptimizerDirs(flowsDestDir);
401
+ console.log('Assets copied successfully.');