@hutusi/amytis 1.12.0 → 1.14.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 (78) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/GEMINI.md +9 -1
  3. package/README.md +26 -17
  4. package/README.zh.md +180 -100
  5. package/bun.lock +78 -74
  6. package/content/books/notes-on-thinking/cost-of-certainty.mdx +9 -0
  7. package/content/books/notes-on-thinking/index.mdx +16 -0
  8. package/content/books/notes-on-thinking/mental-models.mdx +9 -0
  9. package/content/books/the-pragmatic-writer/finding-your-voice.mdx +9 -0
  10. package/content/books/the-pragmatic-writer/index.mdx +18 -0
  11. package/content/books/the-pragmatic-writer/the-editing-loop.mdx +9 -0
  12. package/content/books/the-pragmatic-writer/why-writing-matters.mdx +9 -0
  13. package/content/flows/2026/03/01.md +9 -0
  14. package/content/flows/2026/03/03.md +9 -0
  15. package/content/flows/2026/03/05.md +10 -0
  16. package/content/flows/2026/03/07.md +11 -0
  17. package/content/posts/images/vibrant-waves.jpg +0 -0
  18. package/content/posts/welcome-to-amytis.mdx +3 -0
  19. package/content/series/markdown-showcase/index.mdx +2 -1
  20. package/content/series/markdown-showcase/mathematical-notation.mdx +8 -4
  21. package/content/series/markdown-showcase/syntax-highlighting.mdx +9 -5
  22. package/content/series/markdown-showcase/visuals-and-diagrams.mdx +8 -4
  23. package/content/{posts → series/markdown-showcase}//344/270/255/346/226/207/346/265/213/350/257/225/346/226/207/347/253/240.mdx +12 -7
  24. package/content/series/modern-web-dev/index.mdx +4 -2
  25. package/docs/ARCHITECTURE.md +8 -1
  26. package/docs/DIGITAL_GARDEN.md +22 -1
  27. package/package.json +12 -12
  28. package/public/next-image-export-optimizer-hashes.json +3 -2
  29. package/scripts/new-flow.ts +1 -0
  30. package/site.config.example.ts +3 -4
  31. package/site.config.ts +6 -7
  32. package/src/app/[slug]/[postSlug]/page.tsx +19 -2
  33. package/src/app/[slug]/page/[page]/page.tsx +26 -5
  34. package/src/app/[slug]/page.tsx +28 -8
  35. package/src/app/all.atom/route.ts +7 -0
  36. package/src/app/all.xml/route.ts +7 -0
  37. package/src/app/archive/page.tsx +7 -4
  38. package/src/app/feed.atom/route.ts +2 -57
  39. package/src/app/feed.xml/route.ts +2 -64
  40. package/src/app/flows/[year]/[month]/[day]/page.tsx +13 -0
  41. package/src/app/flows/feed.atom/route.ts +7 -0
  42. package/src/app/flows/feed.xml/route.ts +7 -0
  43. package/src/app/page.tsx +1 -2
  44. package/src/app/posts/[slug]/page.tsx +28 -9
  45. package/src/app/posts/feed.atom/route.ts +9 -0
  46. package/src/app/posts/feed.xml/route.ts +9 -0
  47. package/src/app/series/[slug]/page.tsx +46 -4
  48. package/src/components/CuratedSeriesSection.tsx +7 -11
  49. package/src/components/FeaturedStoriesSection.tsx +1 -1
  50. package/src/components/FlowCalendarSidebar.tsx +1 -1
  51. package/src/components/FlowContent.tsx +2 -1
  52. package/src/components/FlowTimelineEntry.tsx +7 -1
  53. package/src/components/Footer.tsx +6 -6
  54. package/src/components/HorizontalScroll.tsx +5 -14
  55. package/src/components/MarkdownRenderer.test.tsx +6 -0
  56. package/src/components/MarkdownRenderer.tsx +18 -16
  57. package/src/components/Navbar.tsx +1 -1
  58. package/src/components/PostList.tsx +20 -36
  59. package/src/components/PostSidebar.tsx +1 -1
  60. package/src/components/RecentNotesSection.tsx +4 -0
  61. package/src/components/SelectedBooksSection.tsx +65 -25
  62. package/src/components/SeriesCatalog.tsx +9 -7
  63. package/src/i18n/translations.ts +2 -0
  64. package/src/layouts/PostLayout.tsx +1 -1
  65. package/src/layouts/SimpleLayout.tsx +3 -3
  66. package/src/lib/feed-utils.ts +158 -18
  67. package/src/lib/markdown.ts +26 -5
  68. package/src/lib/urls.ts +9 -4
  69. package/tests/e2e/mobile/mobile-compat.spec.ts +58 -0
  70. package/tests/e2e/navigation.test.ts +26 -0
  71. package/tests/integration/collections.test.ts +17 -2
  72. package/tests/integration/feed-utils.test.ts +52 -0
  73. package/tests/integration/flow-title.test.ts +53 -0
  74. package/tests/integration/markdown-features.test.ts +3 -3
  75. package/tests/integration/reading-time-headings.test.ts +2 -2
  76. package/tests/unit/static-params.test.ts +155 -22
  77. package/tests/unit/urls.test.ts +10 -12
  78. /package/content/posts/{multilingual-test.mdx → multilingual-test-/344/270/255/346/226/207/351/225/277/346/240/207/351/242/230.mdx"} +0 -0
@@ -0,0 +1,10 @@
1
+ ---
2
+ title: 'JSDoc type comments'
3
+ tags: ["typescript", "tooling"]
4
+ ---
5
+
6
+ Finally moved a mid-sized project from JSDoc type comments to proper TypeScript. The migration took about half a day — less than expected.
7
+
8
+ The biggest win is not autocompletion or error catching (though those matter). It is that the types act as documentation that stays in sync with the code. Prose comments lie; types don't compile if they do.
9
+
10
+ One friction point: `strict: true` surfaces a lot of implicit `any` that was quietly hiding real assumptions. Fixing each one forced me to think about what the code actually expected — which is the point.
@@ -0,0 +1,11 @@
1
+ ---
2
+ tags: ["ai", "workflow"]
3
+ ---
4
+
5
+ # Using Claude Code
6
+
7
+ Been using Claude Code for a week now as a daily coding assistant. A few observations so far.
8
+
9
+ It is most useful for tasks where the shape of the solution is clear but the execution is tedious — refactoring, writing tests, tracking down why a CSS rule isn't applying. For genuinely novel design problems, talking through the approach matters more than generating code.
10
+
11
+ The most important habit: read every diff before accepting it. Not because the code is wrong (it usually isn't), but because understanding what changed keeps you in the loop on your own codebase.
@@ -5,12 +5,15 @@ excerpt: "The first seeds of our digital garden."
5
5
  category: "General"
6
6
  tags: ["introduction", "digital garden", "nextjs"]
7
7
  author: "Amytis Team"
8
+ coverImage: "images/vibrant-waves.jpg"
8
9
  ---
9
10
 
10
11
  # Welcome to Amytis
11
12
 
12
13
  This is your new digital garden. Built with **Next.js**, **Tailwind CSS**, and **Bun**.
13
14
 
15
+ ![](images/vibrant-waves.jpg)
16
+
14
17
  ## Why Amytis?
15
18
 
16
19
  Amytis of Media was the wife of Nebuchadnezzar II, for whom the Hanging Gardens of Babylon were reportedly built. This digital garden is a space for thoughts to grow and flourish.
@@ -5,7 +5,8 @@ date: "2026-02-13"
5
5
  coverImage: "/images/lake.jpg"
6
6
  featured: true
7
7
  sort: "manual"
8
- posts: ["visuals-and-diagrams", "mathematical-notation", "syntax-highlighting"]
8
+ redirectFrom:
9
+ - /series/markdown-showcase-old
9
10
  ---
10
11
 
11
12
  Amytis supports a wide range of MDX features. This series serves as both a reference and a demonstration.
@@ -1,9 +1,13 @@
1
1
  ---
2
- title: "Mathematical Notation"
3
- date: "2026-02-14"
4
- category: "Science"
5
- tags: ["math", "latex"]
2
+ title: Mathematical Notation
3
+ date: '2026-02-14'
4
+ category: Science
5
+ tags:
6
+ - math
7
+ - latex
6
8
  latex: true
9
+ redirectFrom:
10
+ - /posts/mathematical-notation-demo
7
11
  ---
8
12
 
9
13
  Amytis uses `rehype-katex` to render beautiful mathematical formulas.
@@ -1,10 +1,14 @@
1
1
  ---
2
- title: "Syntax Highlighting"
3
- date: "2026-02-15"
4
- category: "Engineering"
5
- tags: ["code", "typescript"]
2
+ title: Syntax Highlighting
3
+ date: '2026-02-15'
4
+ category: Engineering
5
+ tags:
6
+ - code
7
+ - typescript
6
8
  featured: true
7
- coverImage: "/images/vibrant-waves.jpg"
9
+ coverImage: /images/vibrant-waves.jpg
10
+ redirectFrom:
11
+ - /posts/syntax-highlighting
8
12
  ---
9
13
 
10
14
  Amytis provides beautiful syntax highlighting for dozens of programming languages. Here are a few examples within the series context.
@@ -1,8 +1,12 @@
1
1
  ---
2
- title: "Visuals and Diagrams"
3
- date: "2026-02-13"
4
- category: "Design"
5
- tags: ["visuals", "mermaid"]
2
+ title: Visuals and Diagrams
3
+ date: '2026-02-13'
4
+ category: Design
5
+ tags:
6
+ - visuals
7
+ - mermaid
8
+ redirectFrom:
9
+ - /posts/visuals-and-diagrams
6
10
  ---
7
11
 
8
12
  Visualizing information is key to understanding. Amytis integrates Mermaid.js for diagrams and handles images with ease.
@@ -1,11 +1,16 @@
1
1
  ---
2
- title: "中文测试文章"
3
- date: "2026-02-09"
4
- excerpt: "这是一篇用于测试中文文件名和内容支持的文章。"
5
- category: "测试"
6
- tags: ["中文", "测试", "i18n"]
7
- author: "Amytis"
8
- coverImage: "text:中文测试"
2
+ title: 中文测试文章
3
+ date: '2026-02-09'
4
+ excerpt: 这是一篇用于测试中文文件名和内容支持的文章。
5
+ category: 测试
6
+ tags:
7
+ - 中文
8
+ - 测试
9
+ - i18n
10
+ author: Amytis
11
+ coverImage: 'text:中文测试'
12
+ redirectFrom:
13
+ - /posts/中文测试文章
9
14
  ---
10
15
 
11
16
  # 中文文件名支持测试
@@ -5,9 +5,11 @@ excerpt: "A curated path through modern web development: JavaScript fundamentals
5
5
  date: "2026-03-01"
6
6
  featured: true
7
7
  items:
8
- - post: asynchronous-javascript
9
- - post: understanding-react-hooks
8
+ - post: posts/asynchronous-javascript
9
+ - post: posts/understanding-react-hooks
10
10
  - series: nextjs-deep-dive
11
+ - post: markdown-showcase/中文测试文章
12
+ - post: digital-garden/02-architecture
11
13
  ---
12
14
 
13
15
  This collection assembles the essential reading for anyone building modern web applications. Start with the JavaScript fundamentals that underpin everything, move into React patterns, then go deep on Next.js.
@@ -57,7 +57,14 @@ src/app/
57
57
  authors/[author]/page.tsx
58
58
  archive/page.tsx
59
59
  graph/page.tsx
60
- feed.xml/route.ts
60
+ feed.xml/route.ts # Main curated RSS feed
61
+ feed.atom/route.ts # Main curated Atom feed
62
+ all.xml/route.ts # Firehose RSS feed
63
+ all.atom/route.ts # Firehose Atom feed
64
+ posts/feed.xml/route.ts # Posts-only RSS feed
65
+ posts/feed.atom/route.ts # Posts-only Atom feed
66
+ flows/feed.xml/route.ts # Flows-only RSS feed
67
+ flows/feed.atom/route.ts # Flows-only Atom feed
61
68
  search.json/route.ts
62
69
  sitemap.ts
63
70
  [slug]/page.tsx # Static pages + custom series listing path
@@ -37,12 +37,33 @@ The `/graph` route visualizes your entire digital garden as an interactive netwo
37
37
  - **Edges**: Represent wiki-links connecting them.
38
38
  - **Interaction**: Click a node to navigate to that page.
39
39
 
40
- ### 5. Flows (`/flows`)
40
+ ### 5. Collections (`/series`)
41
+ While a "Series" is typically a linear progression of posts (e.g., Part 1, Part 2), a **Collection** allows you to manually curate an arbitrary list of posts and other series into a single grouped page.
42
+
43
+ To create a collection, set `type: collection` in a series index file:
44
+
45
+ - **Location:** `content/series/[collection-slug]/index.mdx`
46
+ - **Frontmatter:**
47
+ ```yaml
48
+ ---
49
+ title: "Modern Web Development"
50
+ type: collection
51
+ items:
52
+ - post: posts/asynchronous-javascript # Namespaced reference to a standalone post
53
+ - post: ai-nexus-weekly/week-11 # Namespaced reference to a post inside another series
54
+ - post: understanding-react-hooks # Fallback reference (searches all posts by slug)
55
+ - series: nextjs-deep-dive # Embeds all posts from another series
56
+ ---
57
+ ```
58
+ Using a namespace (`folder/slug`) in the `post` definition prevents slug collisions and explicitly targets the exact file location.
59
+
60
+ ### 6. Flows (`/flows`)
41
61
  Flows are a stream-style collection of daily notes, micro-blogging, or imported chat logs. They are ideal for quick thoughts that don't necessarily warrant a full blog post.
42
62
 
43
63
  - **Location:** `content/flows/YYYY/MM/DD.mdx`
44
64
  - **Navigation:** Grouped by date in a timeline view.
45
65
  - **Importing:** Use `bun run new-flow-from-chat` to bring in external conversations.
66
+ - **Optional Title:** Set `title` in frontmatter or use an `# Heading` in the content to display a title alongside the date.
46
67
 
47
68
  ## How to Use
48
69
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hutusi/amytis",
3
- "version": "1.12.0",
3
+ "version": "1.14.0",
4
4
  "description": "A high-performance digital garden and blog engine with Next.js 16 and Tailwind CSS v4",
5
5
  "repository": {
6
6
  "type": "git",
@@ -48,16 +48,16 @@
48
48
  "github-slugger": "^2.0.0",
49
49
  "gray-matter": "^4.0.3",
50
50
  "image-size": "^2.0.2",
51
- "katex": "^0.16.33",
52
- "mermaid": "^11.12.3",
53
- "next": "16.1.6",
51
+ "katex": "^0.16.42",
52
+ "mermaid": "^11.13.0",
53
+ "next": "16.2.1",
54
54
  "next-image-export-optimizer": "^1.20.1",
55
55
  "next-themes": "^0.4.6",
56
56
  "react": "19.2.4",
57
57
  "react-dom": "19.2.4",
58
- "react-icons": "^5.5.0",
58
+ "react-icons": "^5.6.0",
59
59
  "react-markdown": "^10.1.0",
60
- "react-syntax-highlighter": "^16.1.0",
60
+ "react-syntax-highlighter": "^16.1.1",
61
61
  "rehype-katex": "^7.0.1",
62
62
  "rehype-raw": "^7.0.0",
63
63
  "rehype-slug": "^6.0.0",
@@ -72,22 +72,22 @@
72
72
  },
73
73
  "devDependencies": {
74
74
  "@playwright/test": "^1.58.2",
75
- "@tailwindcss/postcss": "^4.1.18",
76
- "@types/bun": "^1.3.9",
75
+ "@tailwindcss/postcss": "^4.2.2",
76
+ "@types/bun": "^1.3.11",
77
77
  "@types/d3": "^7.4.3",
78
78
  "@types/hast": "^3.0.4",
79
79
  "@types/image-size": "^0.8.0",
80
80
  "@types/mdast": "^4.0.4",
81
- "@types/node": "^24.10.13",
81
+ "@types/node": "^24.12.0",
82
82
  "@types/react": "^19.2.14",
83
83
  "@types/react-dom": "^19.2.3",
84
84
  "@types/react-syntax-highlighter": "^15.5.13",
85
85
  "babel-plugin-react-compiler": "1.0.0",
86
- "eslint": "^9.0.0",
87
- "eslint-config-next": "16.1.6",
86
+ "eslint": "^9.39.4",
87
+ "eslint-config-next": "16.2.1",
88
88
  "pagefind": "^1.4.0",
89
89
  "pdf-to-img": "^5.0.0",
90
- "tailwindcss": "^4.1.18",
90
+ "tailwindcss": "^4.2.2",
91
91
  "typescript": "^5.9.3"
92
92
  },
93
93
  "ignoreScripts": [
@@ -68,7 +68,8 @@
68
68
  "images/lake.jpg": "imAORQhxpmoU3jzKBMNFJuFSa0UgiSf2Dmea5Rj8-8M=",
69
69
  "images/mountains.jpg": "FsrkZws9EKMqHCk1Hc6i6nEIcTRcrMBa4ddgqR6oRaI=",
70
70
  "images/screenshot.png": "FAqbAgLRbWbYq9yJ4iggq2aKxRD8hdeDICc3DI14yhg=",
71
- "images/vibrant-waves.avif": "pP6wNch0dvCr+HGjkA8zIT6ZuZCSSofiVkG922udk54=",
71
+ "images/vibrant-waves.jpg": "vdBm72ev5ETPM5H2CDK6tqph5t8N5nTCSApBJp2lW6U=",
72
72
  "images/wechat-qr.jpg": "DNIzz0Wcl8WP0h-jHHgZ9LEvf3ZKOXHgxlvpw3gK2ME=",
73
- "posts/02-routing-mastery/assets/m-p-model.png": "fDmvlEkZnE-UCvPK4gmDkJD7SU8coOTl4iw5hpsmcWI="
73
+ "posts/02-routing-mastery/assets/m-p-model.png": "fDmvlEkZnE-UCvPK4gmDkJD7SU8coOTl4iw5hpsmcWI=",
74
+ "posts/images/vibrant-waves.jpg": "YogfFJOm4PQV1g3iMRpCL1pKtjPXpMeJDFf6NTILcBQ="
74
75
  }
@@ -33,6 +33,7 @@ if (!fs.existsSync(dirPath)) {
33
33
  }
34
34
 
35
35
  const content = `---
36
+ # title: ""
36
37
  tags: []
37
38
  ---
38
39
 
@@ -143,7 +143,7 @@ export const siteConfig = {
143
143
  { id: 'featured-posts', enabled: true, weight: 2, maxItems: 4 },
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, scrollThreshold: 2 },
146
+ { id: 'featured-series', enabled: true, weight: 5, maxItems: 6 },
147
147
  { id: 'featured-books', enabled: false, weight: 6, maxItems: 4 },
148
148
  ],
149
149
  },
@@ -177,10 +177,9 @@ export const siteConfig = {
177
177
  },
178
178
  series: {
179
179
  // When true, posts in a series are served at /[series-slug]/[post-slug]
180
- // instead of the default posts basePath. Defaults to false to avoid breaking
181
- // existing deployments — enable explicitly and run `add-series-redirects` first.
180
+ // instead of the default posts basePath. Defaults to true.
182
181
  // customPaths entries always take precedence over autoPaths.
183
- autoPaths: false,
182
+ autoPaths: true,
184
183
  // Per-series custom URL prefix for posts within that series.
185
184
  // Overrides autoPaths for the specified series.
186
185
  // e.g., { 'weeklies': 'weeklies' } → posts served at /weeklies/[slug]
package/site.config.ts CHANGED
@@ -33,7 +33,7 @@ export const siteConfig = {
33
33
  favicon: "/icon.svg",
34
34
  },
35
35
  description: { en: "Amytis — an elegant open-source framework for building your personal digital garden.", zh: "Amytis — 优雅的开源数字花园框架。" },
36
- baseUrl: "https://example.com", // Replace with your actual domain
36
+ baseUrl: "https://amytis.vercel.app", // Replace with your actual domain
37
37
  ogImage: "/og-image.png", // Default OG/social preview image — place a 1200×630 PNG at public/og-image.png
38
38
  footerText: { en: `© ${new Date().getFullYear()} Amytis. All rights reserved.`, zh: `© ${new Date().getFullYear()} Amytis. 保留所有权利。` },
39
39
 
@@ -140,9 +140,9 @@ export const siteConfig = {
140
140
  sections: [
141
141
  { id: 'hero', enabled: true, weight: 1 },
142
142
  { id: 'featured-posts', enabled: true, weight: 2, maxItems: 4 },
143
- { id: 'latest-posts', enabled: true, weight: 3, maxItems: 3 },
144
- { id: 'recent-flows', enabled: true, weight: 4, maxItems: 8 },
145
- { id: 'featured-series', enabled: true, weight: 5, maxItems: 6, scrollThreshold: 2 },
143
+ { id: 'latest-posts', enabled: true, weight: 3, maxItems: 4 },
144
+ { id: 'recent-flows', enabled: true, weight: 4, maxItems: 7 },
145
+ { id: 'featured-series', enabled: true, weight: 5, maxItems: 6 },
146
146
  { id: 'featured-books', enabled: true, weight: 6, maxItems: 4 },
147
147
  ],
148
148
  },
@@ -176,10 +176,9 @@ export const siteConfig = {
176
176
  },
177
177
  series: {
178
178
  // When true, posts in a series are served at /[series-slug]/[post-slug]
179
- // instead of the default posts basePath. Defaults to false to avoid breaking
180
- // existing deployments — enable explicitly and run `add-series-redirects` first.
179
+ // instead of the default posts basePath. Defaults to true.
181
180
  // customPaths entries always take precedence over autoPaths.
182
- autoPaths: false,
181
+ autoPaths: true,
183
182
  // Per-series custom URL prefix for posts within that series.
184
183
  // Overrides autoPaths for the specified series.
185
184
  // e.g., { 'weeklies': 'weeklies' } → posts served at /weeklies/[slug]
@@ -49,7 +49,7 @@ export async function generateStaticParams() {
49
49
  const pageSlugSet = getAllPages().map(p => p.slug);
50
50
  validateSeriesAutoPaths(allSeriesSlugs, [...pageSlugSet, ...Object.values(customPaths)]); // Throws if any slug collides with a reserved route, static page, or customPaths prefix
51
51
  for (const seriesSlug of allSeriesSlugs) {
52
- if (seriesSlug in customPaths) continue; // Already handled by customPaths above
52
+ if (Object.hasOwn(customPaths, seriesSlug)) continue; // Already handled by customPaths above
53
53
  allSeriesMap[seriesSlug].forEach(post => { params.push({ slug: seriesSlug, postSlug: post.slug }); });
54
54
  }
55
55
  }
@@ -61,10 +61,27 @@ export async function generateStaticParams() {
61
61
  if (segments.length !== 2) continue;
62
62
  const [fromPrefix, fromPostSlug] = segments;
63
63
  if (from === getPostUrl(post)) continue; // skip if this is already the canonical path
64
+ // Skip /posts/* entries when basePath is 'posts' — handled by posts/[slug]/page.tsx instead
65
+ if (fromPrefix === 'posts' && basePath === 'posts') continue;
64
66
  params.push({ slug: fromPrefix, postSlug: fromPostSlug });
65
67
  }
66
68
  }
67
69
 
70
+ // Work around Next dev static-param checks for percent-encoded Unicode postSlugs
71
+ // under `output: "export"` — dev server may receive percent-encoded forms of Unicode paths.
72
+ // Include encoded variants in development only; production export keeps raw segment values.
73
+ if (process.env.NODE_ENV !== 'production') {
74
+ const existing = new Set(params.map(p => `${p.slug}/${p.postSlug}`));
75
+ for (const p of [...params]) {
76
+ const encodedPostSlug = encodeURIComponent(p.postSlug);
77
+ const key = `${p.slug}/${encodedPostSlug}`;
78
+ if (!existing.has(key)) {
79
+ existing.add(key);
80
+ params.push({ slug: p.slug, postSlug: encodedPostSlug });
81
+ }
82
+ }
83
+ }
84
+
68
85
  // Placeholder keeps Next.js happy with output: export when no custom paths configured.
69
86
  // dynamicParams = false ensures any unrecognised slug/postSlug combo returns 404.
70
87
  return params.length > 0 ? params : [{ slug: '_', postSlug: '_' }];
@@ -153,7 +170,7 @@ export default async function PrefixPostPage({
153
170
  const customPaths = getSeriesCustomPaths();
154
171
  const isValidBasePath = prefix === basePath && basePath !== 'posts';
155
172
  const matchedSeriesSlug = Object.entries(customPaths).find(([, path]) => path === prefix)?.[0];
156
- const isAutoSeriesPath = getSeriesAutoPaths() && !(prefix in customPaths) && getSeriesData(prefix) !== null;
173
+ const isAutoSeriesPath = getSeriesAutoPaths() && !Object.hasOwn(customPaths, prefix) && getSeriesData(prefix) !== null;
157
174
  const isLegacyRedirect = post.redirectFrom?.includes(currentPath) ?? false;
158
175
 
159
176
  if (!isValidBasePath && !matchedSeriesSlug && !isAutoSeriesPath && !isLegacyRedirect) {
@@ -1,4 +1,4 @@
1
- import { getListingPosts, getSeriesData, getSeriesPosts, getSeriesAuthors, getAuthorSlug } from '@/lib/markdown';
1
+ import { getListingPosts, getAllSeries, getSeriesData, getSeriesPosts, getSeriesAuthors, getAuthorSlug } from '@/lib/markdown';
2
2
  import PostList from '@/components/PostList';
3
3
  import SeriesCatalog from '@/components/SeriesCatalog';
4
4
  import Pagination from '@/components/Pagination';
@@ -9,11 +9,18 @@ import { t, resolveLocale } from '@/lib/i18n';
9
9
  import PageHeader from '@/components/PageHeader';
10
10
  import CoverImage from '@/components/CoverImage';
11
11
  import Link from 'next/link';
12
- import { getPostsBasePath, getSeriesCustomPaths } from '@/lib/urls';
12
+ import { getPostsBasePath, getSeriesCustomPaths, getSeriesAutoPaths } from '@/lib/urls';
13
13
 
14
14
  const POST_PAGE_SIZE = siteConfig.pagination.posts;
15
15
  const SERIES_PAGE_SIZE = siteConfig.pagination.series;
16
16
 
17
+ function resolveSeriesSlug(prefix: string, customPaths: Record<string, string>): string | undefined {
18
+ return (
19
+ Object.entries(customPaths).find(([, path]) => path === prefix)?.[0] ??
20
+ (getSeriesAutoPaths() && !Object.hasOwn(customPaths, prefix) && getSeriesData(prefix) ? prefix : undefined)
21
+ );
22
+ }
23
+
17
24
  export async function generateStaticParams() {
18
25
  const params: { slug: string; page: string }[] = [];
19
26
 
@@ -28,7 +35,8 @@ export async function generateStaticParams() {
28
35
  }
29
36
 
30
37
  // Series custom paths — paginated series listing (page 2+)
31
- for (const [seriesSlug, customPath] of Object.entries(getSeriesCustomPaths())) {
38
+ const customPaths = getSeriesCustomPaths();
39
+ for (const [seriesSlug, customPath] of Object.entries(customPaths)) {
32
40
  const posts = getSeriesPosts(seriesSlug);
33
41
  const totalPages = Math.ceil(posts.length / SERIES_PAGE_SIZE);
34
42
  for (let i = 2; i <= totalPages; i++) {
@@ -36,6 +44,19 @@ export async function generateStaticParams() {
36
44
  }
37
45
  }
38
46
 
47
+ // Series auto-paths — paginated series listing (page 2+)
48
+ const customPathValues = new Set(Object.values(customPaths));
49
+ if (getSeriesAutoPaths()) {
50
+ for (const [seriesSlug, posts] of Object.entries(getAllSeries())) {
51
+ if (Object.hasOwn(customPaths, seriesSlug)) continue; // series has its own customPaths key override — skip
52
+ if (customPathValues.has(seriesSlug)) continue; // slug collides with another series' custom path value — skip
53
+ const totalPages = Math.ceil(posts.length / SERIES_PAGE_SIZE);
54
+ for (let i = 2; i <= totalPages; i++) {
55
+ params.push({ slug: seriesSlug, page: i.toString() });
56
+ }
57
+ }
58
+ }
59
+
39
60
  // Placeholder keeps Next.js happy with output: export when no custom paths configured.
40
61
  // dynamicParams = false ensures any unrecognised slug/page combo returns 404.
41
62
  return params.length > 0 ? params : [{ slug: '_', page: '2' }];
@@ -51,7 +72,7 @@ export async function generateMetadata({
51
72
  const { slug: prefix, page } = await params;
52
73
  const basePath = getPostsBasePath();
53
74
  const customPaths = getSeriesCustomPaths();
54
- const matchedSeriesSlug = Object.entries(customPaths).find(([, path]) => path === prefix)?.[0];
75
+ const matchedSeriesSlug = resolveSeriesSlug(prefix, customPaths);
55
76
 
56
77
  if (prefix === basePath && basePath !== 'posts') {
57
78
  return {
@@ -82,7 +103,7 @@ export default async function PrefixPageRoute({
82
103
 
83
104
  const basePath = getPostsBasePath();
84
105
  const customPaths = getSeriesCustomPaths();
85
- const matchedSeriesSlug = Object.entries(customPaths).find(([, path]) => path === prefix)?.[0];
106
+ const matchedSeriesSlug = resolveSeriesSlug(prefix, customPaths);
86
107
 
87
108
  // Custom posts basePath listing
88
109
  if (prefix === basePath && basePath !== 'posts') {
@@ -1,4 +1,4 @@
1
- import { getPageBySlug, getAllPages, getAllPosts, getListingPosts, getSeriesData, getSeriesPosts, getSeriesAuthors, getAuthorSlug } from '@/lib/markdown';
1
+ import { getPageBySlug, getAllPages, getAllPosts, getAllSeries, getListingPosts, getSeriesData, getSeriesPosts, getSeriesAuthors, getAuthorSlug } from '@/lib/markdown';
2
2
  import { notFound } from 'next/navigation';
3
3
  import PostLayout from '@/layouts/PostLayout';
4
4
  import SimpleLayout from '@/layouts/SimpleLayout';
@@ -11,12 +11,19 @@ import { Metadata } from 'next';
11
11
  import { siteConfig } from '../../../site.config';
12
12
  import { resolveLocale, t } from '@/lib/i18n';
13
13
  import PageHeader from '@/components/PageHeader';
14
- import { getPostsBasePath, getSeriesCustomPaths, getPostUrl } from '@/lib/urls';
14
+ import { getPostsBasePath, getSeriesCustomPaths, getSeriesAutoPaths, getPostUrl, RESERVED_ROUTE_SEGMENTS } from '@/lib/urls';
15
15
  import RedirectPage from '@/components/RedirectPage';
16
16
 
17
17
  const POST_PAGE_SIZE = siteConfig.pagination.posts;
18
18
  const SERIES_PAGE_SIZE = siteConfig.pagination.series;
19
19
 
20
+ function resolveSeriesSlug(slug: string, customPaths: Record<string, string>): string | undefined {
21
+ return (
22
+ Object.entries(customPaths).find(([, path]) => path === slug)?.[0] ??
23
+ (getSeriesAutoPaths() && !Object.hasOwn(customPaths, slug) && getSeriesData(slug) ? slug : undefined)
24
+ );
25
+ }
26
+
20
27
  /**
21
28
  * Generates the static paths for all top-level pages at build time,
22
29
  * plus any custom URL prefixes configured for posts or series.
@@ -32,19 +39,32 @@ export async function generateStaticParams() {
32
39
  }
33
40
 
34
41
  // Add series custom path listings (e.g. /weeklies)
35
- for (const customPath of Object.values(getSeriesCustomPaths())) {
42
+ const customPaths = getSeriesCustomPaths();
43
+ for (const customPath of Object.values(customPaths)) {
36
44
  params.push({ slug: customPath });
37
45
  }
38
46
 
47
+ // Add series auto-path listings (e.g. /my-series) when autoPaths is enabled
48
+ const customPathValues = new Set(Object.values(customPaths));
49
+ const autoPathSlugs: string[] = [];
50
+ if (getSeriesAutoPaths()) {
51
+ for (const seriesSlug of Object.keys(getAllSeries())) {
52
+ if (Object.hasOwn(customPaths, seriesSlug)) continue; // series has its own customPaths key override — skip
53
+ if (customPathValues.has(seriesSlug)) continue; // slug collides with another series' custom path value — skip
54
+ autoPathSlugs.push(seriesSlug);
55
+ params.push({ slug: seriesSlug });
56
+ }
57
+ }
58
+
39
59
  // Add single-segment redirectFrom paths (e.g. /old-slug).
40
60
  // Throws on any alias that collides with a reserved top-level slug or a
41
61
  // duplicate alias across posts — strict build catches misconfiguration early.
42
62
  const reservedSlugs = new Set([
43
63
  ...pages.map(p => p.slug),
44
64
  basePath,
45
- ...Object.values(getSeriesCustomPaths()),
46
- // Hardcoded top-level routes that have their own app/ directories
47
- 'posts', 'series', 'tags', 'authors', 'archive', 'books', 'flows', 'notes', 'search', 'page',
65
+ ...Object.values(customPaths),
66
+ ...autoPathSlugs,
67
+ ...RESERVED_ROUTE_SEGMENTS,
48
68
  ]);
49
69
  for (const post of getAllPosts()) {
50
70
  for (const from of post.redirectFrom ?? []) {
@@ -82,7 +102,7 @@ export async function generateMetadata({ params }: { params: Promise<{ slug: str
82
102
 
83
103
  // Series custom paths
84
104
  const customPaths = getSeriesCustomPaths();
85
- const matchedSeriesSlug = Object.entries(customPaths).find(([, path]) => path === slug)?.[0];
105
+ const matchedSeriesSlug = resolveSeriesSlug(slug, customPaths);
86
106
  if (matchedSeriesSlug) {
87
107
  const seriesData = getSeriesData(matchedSeriesSlug);
88
108
  if (seriesData) {
@@ -145,7 +165,7 @@ export default async function Page({
145
165
 
146
166
  // Check if slug matches a series custom path
147
167
  const customPaths = getSeriesCustomPaths();
148
- const matchedSeriesSlug = Object.entries(customPaths).find(([, path]) => path === slug)?.[0];
168
+ const matchedSeriesSlug = resolveSeriesSlug(slug, customPaths);
149
169
  if (matchedSeriesSlug) {
150
170
  const seriesData = getSeriesData(matchedSeriesSlug);
151
171
  const allPosts = getSeriesPosts(matchedSeriesSlug);
@@ -0,0 +1,7 @@
1
+ import { generateAtomFeed } from '@/lib/feed-utils';
2
+
3
+ export const dynamic = 'force-static';
4
+
5
+ export async function GET() {
6
+ return generateAtomFeed('all', '/all.atom');
7
+ }
@@ -0,0 +1,7 @@
1
+ import { generateRssFeed } from '@/lib/feed-utils';
2
+
3
+ export const dynamic = 'force-static';
4
+
5
+ export async function GET() {
6
+ return generateRssFeed('all', '/all.xml');
7
+ }
@@ -128,12 +128,12 @@ export default function ArchivePage() {
128
128
  <li key={post.slug} className="group">
129
129
  <Link href={getPostUrl(post)} className="block no-underline">
130
130
  <div className="flex flex-col sm:flex-row sm:items-baseline justify-between gap-2 sm:gap-6">
131
- <div className="flex items-baseline gap-6">
131
+ <div className="flex items-baseline gap-6 min-w-0 flex-1">
132
132
  <span className="font-mono text-base text-muted shrink-0 w-8">
133
133
  {day}
134
134
  </span>
135
- <div className="flex items-baseline gap-2">
136
- <h4 className="text-xl font-serif font-medium text-heading/80 group-hover:text-accent transition-colors duration-200">
135
+ <div className="flex items-baseline gap-2 min-w-0 flex-1">
136
+ <h4 className="text-xl font-serif font-medium text-heading/80 group-hover:text-accent transition-colors duration-200 truncate">
137
137
  {post.title}
138
138
  </h4>
139
139
  {post.series && (
@@ -147,7 +147,10 @@ export default function ArchivePage() {
147
147
  </div>
148
148
  </div>
149
149
  {showAuthors && post.authors.length > 0 && (
150
- <span className="text-sm font-sans italic text-muted/60 shrink-0 hidden sm:block">
150
+ <span
151
+ title={post.authors.join(', ')}
152
+ className="text-sm font-sans italic text-muted/60 hidden sm:block max-w-[12rem] md:max-w-[16rem] lg:max-w-[20rem] truncate text-right shrink-0"
153
+ >
151
154
  {post.authors.join(', ')}
152
155
  </span>
153
156
  )}