@hutusi/amytis 1.9.0 → 1.11.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 (143) hide show
  1. package/.entire/settings.json +4 -0
  2. package/AGENTS.md +13 -7
  3. package/CHANGELOG.md +41 -0
  4. package/CLAUDE.md +30 -2
  5. package/README.md +84 -58
  6. package/README.zh.md +195 -0
  7. package/TODO.md +6 -4
  8. package/bun.lock +30 -4
  9. package/content/books/sample-book/images/digital-garden.svg +25 -0
  10. package/content/books/sample-book/index.mdx +1 -1
  11. package/content/books/sample-book/introduction.mdx +2 -0
  12. package/content/posts/2026-01-21-kitchen-sink/index.mdx +26 -1
  13. package/content/posts/legacy-markdown.md +0 -2
  14. package/content/posts/markdown-features.mdx +1 -1
  15. package/content/posts/multilingual-test.mdx +0 -1
  16. package/content/posts/syntax-highlighting-showcase.mdx +1 -0
  17. package/content/posts/understanding-react-hooks.mdx +1 -0
  18. package/content/series/ai-nexus-weekly/index.mdx +1 -1
  19. package/content/series/digital-garden/01-philosophy.mdx +2 -0
  20. package/content/series/digital-garden/index.mdx +1 -1
  21. package/content/series/markdown-showcase/index.mdx +1 -1
  22. package/content/series/markdown-showcase/syntax-highlighting.mdx +2 -0
  23. package/content/series/nextjs-deep-dive/01-getting-started.mdx +2 -1
  24. package/content/series/nextjs-deep-dive/index.mdx +1 -1
  25. package/content/subscribe.mdx +27 -0
  26. package/docs/ARCHITECTURE.md +85 -105
  27. package/docs/CONTRIBUTING.md +11 -3
  28. package/docs/DIGITAL_GARDEN.md +11 -9
  29. package/docs/deployment.md +31 -13
  30. package/eslint.config.mjs +2 -0
  31. package/package.json +13 -4
  32. package/packages/create-amytis/package.json +13 -0
  33. package/packages/create-amytis/src/index.test.ts +187 -0
  34. package/packages/create-amytis/src/index.ts +230 -0
  35. package/packages/create-amytis/tsconfig.json +14 -0
  36. package/public/images/amytis-screenshot.jpg +0 -0
  37. package/public/images/antelope-canyon.jpg +0 -0
  38. package/public/images/avatar.jpg +0 -0
  39. package/public/images/cappadocia.jpg +0 -0
  40. package/public/images/flowers.jpg +0 -0
  41. package/public/images/galaxy.jpg +0 -0
  42. package/public/images/lake.jpg +0 -0
  43. package/public/images/mountains.jpg +0 -0
  44. package/public/images/vibrant-waves.avif +0 -0
  45. package/public/images/wechat-qr.jpg +0 -0
  46. package/public/next-image-export-optimizer-hashes.json +72 -2
  47. package/scripts/deploy.ts +77 -0
  48. package/site.config.example.ts +268 -0
  49. package/site.config.ts +82 -10
  50. package/src/app/[slug]/[postSlug]/page.tsx +161 -0
  51. package/src/app/[slug]/page/[page]/page.tsx +202 -0
  52. package/src/app/[slug]/page.tsx +162 -7
  53. package/src/app/archive/page.tsx +5 -8
  54. package/src/app/books/[slug]/[chapter]/page.tsx +45 -3
  55. package/src/app/books/[slug]/page.tsx +16 -2
  56. package/src/app/books/page.tsx +12 -8
  57. package/src/app/feed.atom/route.ts +62 -0
  58. package/src/app/feed.xml/route.ts +45 -20
  59. package/src/app/flows/[year]/[month]/[day]/page.tsx +9 -1
  60. package/src/app/flows/[year]/page.tsx +1 -1
  61. package/src/app/flows/page/[page]/page.tsx +2 -0
  62. package/src/app/flows/page.tsx +4 -0
  63. package/src/app/globals.css +9 -3
  64. package/src/app/layout.tsx +22 -1
  65. package/src/app/notes/[slug]/page.tsx +2 -3
  66. package/src/app/page.tsx +86 -8
  67. package/src/app/posts/[slug]/page.tsx +33 -7
  68. package/src/app/posts/page/[page]/page.tsx +9 -5
  69. package/src/app/posts/page.tsx +13 -7
  70. package/src/app/series/[slug]/page/[page]/page.tsx +8 -25
  71. package/src/app/series/[slug]/page.tsx +21 -19
  72. package/src/app/series/page.tsx +14 -28
  73. package/src/app/sitemap.ts +2 -1
  74. package/src/app/tags/[tag]/page.tsx +1 -1
  75. package/src/app/tags/page.tsx +1 -1
  76. package/src/components/Analytics.tsx +34 -36
  77. package/src/components/AuthorCard.tsx +56 -14
  78. package/src/components/BookMobileNav.tsx +8 -34
  79. package/src/components/BookSidebar.tsx +17 -107
  80. package/src/components/CodeBlock.test.tsx +19 -0
  81. package/src/components/CodeBlock.tsx +20 -16
  82. package/src/components/Comments.tsx +4 -3
  83. package/src/components/CoverImage.tsx +11 -3
  84. package/src/components/CuratedSeriesSection.tsx +17 -15
  85. package/src/components/ExternalLinks.tsx +1 -1
  86. package/src/components/FeaturedStoriesSection.tsx +124 -58
  87. package/src/components/FlowCalendarSidebar.tsx +10 -7
  88. package/src/components/FlowContent.tsx +33 -5
  89. package/src/components/Footer.tsx +28 -6
  90. package/src/components/Hero.tsx +44 -92
  91. package/src/components/InlineBookToc.tsx +56 -0
  92. package/src/components/KnowledgeGraph.tsx +26 -0
  93. package/src/components/LanguageProvider.tsx +7 -2
  94. package/src/components/LanguageSwitch.tsx +1 -1
  95. package/src/components/LatestWritingSection.tsx +12 -22
  96. package/src/components/MarkdownRenderer.test.tsx +41 -0
  97. package/src/components/MarkdownRenderer.tsx +43 -8
  98. package/src/components/Navbar.tsx +24 -15
  99. package/src/components/NoteSidebar.tsx +6 -76
  100. package/src/components/Pagination.tsx +2 -2
  101. package/src/components/PostCard.tsx +2 -1
  102. package/src/components/PostList.tsx +81 -62
  103. package/src/components/PostNavigation.tsx +8 -7
  104. package/src/components/PostSidebar.tsx +12 -107
  105. package/src/components/PrevNextNav.tsx +63 -0
  106. package/src/components/RecentNotesSection.tsx +2 -2
  107. package/src/components/RelatedPosts.tsx +3 -2
  108. package/src/components/RssFeedWidget.tsx +55 -0
  109. package/src/components/SectionLabel.tsx +16 -0
  110. package/src/components/SelectedBooksSection.tsx +12 -15
  111. package/src/components/SeriesCatalog.tsx +74 -69
  112. package/src/components/SeriesList.tsx +11 -36
  113. package/src/components/SeriesSidebar.tsx +4 -2
  114. package/src/components/TagContentTabs.tsx +17 -12
  115. package/src/components/TagPageHeader.tsx +12 -2
  116. package/src/components/TagSidebar.tsx +3 -2
  117. package/src/components/TagsIndexClient.tsx +1 -1
  118. package/src/components/TocPanel.tsx +67 -0
  119. package/src/hooks/useActiveHeading.ts +32 -0
  120. package/src/hooks/useSidebarAutoScroll.ts +19 -0
  121. package/src/i18n/translations.ts +26 -4
  122. package/src/layouts/BookLayout.tsx +10 -40
  123. package/src/layouts/PostLayout.tsx +37 -26
  124. package/src/layouts/SimpleLayout.tsx +5 -5
  125. package/src/lib/feed-utils.ts +68 -0
  126. package/src/lib/format-utils.test.ts +27 -0
  127. package/src/lib/format-utils.ts +3 -0
  128. package/src/lib/image-utils.ts +12 -0
  129. package/src/lib/json-ld.ts +236 -0
  130. package/src/lib/markdown.ts +62 -7
  131. package/src/lib/rehype-image-metadata.test.ts +124 -0
  132. package/src/lib/rehype-image-metadata.ts +21 -5
  133. package/src/lib/scroll-utils.ts +11 -0
  134. package/src/lib/shuffle.ts +21 -0
  135. package/src/lib/urls.ts +51 -0
  136. package/tests/e2e/navigation.test.ts +15 -1
  137. package/tests/integration/books.test.ts +61 -0
  138. package/tests/integration/feed-utils.test.ts +145 -0
  139. package/tests/integration/series.test.ts +9 -0
  140. package/tests/unit/static-params.test.ts +59 -2
  141. package/src/app/subscribe/page.tsx +0 -17
  142. package/src/components/SubscribePage.tsx +0 -298
  143. /package/public/{screenshot.png → images/screenshot.png} +0 -0
@@ -0,0 +1,161 @@
1
+ import { getPostBySlug, getAllPosts, getRelatedPosts, getSeriesPosts, getSeriesData, getAdjacentPosts, buildSlugRegistry, getBacklinks, PostData } from '@/lib/markdown';
2
+ import { notFound } from 'next/navigation';
3
+ import PostLayout from '@/layouts/PostLayout';
4
+ import SimpleLayout from '@/layouts/SimpleLayout';
5
+ import { Metadata } from 'next';
6
+ import { siteConfig } from '../../../../site.config';
7
+ import { resolveLocale } from '@/lib/i18n';
8
+ import { getPostsBasePath, getSeriesCustomPaths, getPostUrl } from '@/lib/urls';
9
+ import { buildPostJsonLd, serializeJsonLd } from '@/lib/json-ld';
10
+
11
+ function safeDecodeParam(param: string): string {
12
+ try {
13
+ return decodeURIComponent(param);
14
+ } catch {
15
+ return param;
16
+ }
17
+ }
18
+
19
+ function resolvePostFromParam(rawSlug: string) {
20
+ const decoded = safeDecodeParam(rawSlug);
21
+ return (
22
+ getPostBySlug(decoded) ||
23
+ getPostBySlug(rawSlug) ||
24
+ getPostBySlug(decoded.normalize('NFC')) ||
25
+ getPostBySlug(decoded.normalize('NFD'))
26
+ );
27
+ }
28
+
29
+ export async function generateStaticParams() {
30
+ const params: { slug: string; postSlug: string }[] = [];
31
+
32
+ // Custom posts basePath — all posts served at /[basePath]/[slug]
33
+ const basePath = getPostsBasePath();
34
+ if (basePath !== 'posts') {
35
+ getAllPosts().forEach(post => { params.push({ slug: basePath, postSlug: post.slug }); });
36
+ }
37
+
38
+ // Series custom paths — only posts belonging to that series
39
+ for (const [seriesSlug, customPath] of Object.entries(getSeriesCustomPaths())) {
40
+ getSeriesPosts(seriesSlug).forEach(post => { params.push({ slug: customPath, postSlug: post.slug }); });
41
+ }
42
+
43
+ // Placeholder keeps Next.js happy with output: export when no custom paths configured.
44
+ // dynamicParams = false ensures any unrecognised slug/postSlug combo returns 404.
45
+ return params.length > 0 ? params : [{ slug: '_', postSlug: '_' }];
46
+ }
47
+
48
+ export const dynamicParams = false;
49
+
50
+ export async function generateMetadata({
51
+ params,
52
+ }: {
53
+ params: Promise<{ slug: string; postSlug: string }>;
54
+ }): Promise<Metadata> {
55
+ const { postSlug: rawPostSlug } = await params;
56
+ const post = resolvePostFromParam(rawPostSlug);
57
+
58
+ if (!post) {
59
+ return { title: 'Post Not Found' };
60
+ }
61
+
62
+ const ogImage =
63
+ post.coverImage && !post.coverImage.startsWith('text:') && !post.coverImage.startsWith('./')
64
+ ? post.coverImage
65
+ : siteConfig.ogImage;
66
+
67
+ return {
68
+ title: `${post.title} | ${resolveLocale(siteConfig.title)}`,
69
+ description: post.excerpt,
70
+ openGraph: {
71
+ title: post.title,
72
+ description: post.excerpt,
73
+ type: 'article',
74
+ publishedTime: post.date,
75
+ authors: post.authors,
76
+ images: [
77
+ {
78
+ url: ogImage,
79
+ width: 1200,
80
+ height: 630,
81
+ alt: post.title,
82
+ },
83
+ ],
84
+ siteName: resolveLocale(siteConfig.title),
85
+ },
86
+ twitter: {
87
+ card: 'summary_large_image',
88
+ title: post.title,
89
+ description: post.excerpt,
90
+ images: [ogImage],
91
+ },
92
+ };
93
+ }
94
+
95
+ export default async function PrefixPostPage({
96
+ params,
97
+ }: {
98
+ params: Promise<{ slug: string; postSlug: string }>;
99
+ }) {
100
+ const { slug: prefix, postSlug: rawPostSlug } = await params;
101
+
102
+ // Validate the prefix is a known custom path
103
+ const basePath = getPostsBasePath();
104
+ const customPaths = getSeriesCustomPaths();
105
+ const isValidBasePath = prefix === basePath && basePath !== 'posts';
106
+ const matchedSeriesSlug = Object.entries(customPaths).find(([, path]) => path === prefix)?.[0];
107
+
108
+ if (!isValidBasePath && !matchedSeriesSlug) {
109
+ notFound();
110
+ }
111
+
112
+ const post = resolvePostFromParam(rawPostSlug);
113
+ if (!post) {
114
+ notFound();
115
+ }
116
+
117
+ const layout = post.layout || 'post';
118
+
119
+ const siteUrl = siteConfig.baseUrl.replace(/\/+$/, '');
120
+ const jsonLd = buildPostJsonLd({
121
+ post,
122
+ postUrl: `${siteUrl}${getPostUrl(post)}`,
123
+ siteTitle: resolveLocale(siteConfig.title),
124
+ siteUrl,
125
+ defaultOgImage: siteConfig.ogImage,
126
+ });
127
+ const jsonLdScript = <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: serializeJsonLd(jsonLd) }} />;
128
+
129
+ if (layout === 'simple') {
130
+ return <>{jsonLdScript}<SimpleLayout post={post} /></>;
131
+ }
132
+
133
+ const relatedPosts = getRelatedPosts(post.slug);
134
+ const { prev, next } = getAdjacentPosts(post.slug);
135
+ const slugRegistry = buildSlugRegistry();
136
+ const backlinks = getBacklinks(post.slug);
137
+ let seriesPosts: PostData[] = [];
138
+ let seriesTitle: string | undefined;
139
+
140
+ if (post.series) {
141
+ seriesPosts = getSeriesPosts(post.series);
142
+ const seriesData = getSeriesData(post.series);
143
+ seriesTitle = seriesData?.title;
144
+ }
145
+
146
+ return (
147
+ <>
148
+ {jsonLdScript}
149
+ <PostLayout
150
+ post={post}
151
+ relatedPosts={relatedPosts}
152
+ seriesPosts={seriesPosts}
153
+ seriesTitle={seriesTitle}
154
+ prevPost={prev}
155
+ nextPost={next}
156
+ backlinks={backlinks}
157
+ slugRegistry={slugRegistry}
158
+ />
159
+ </>
160
+ );
161
+ }
@@ -0,0 +1,202 @@
1
+ import { getListingPosts, getSeriesData, getSeriesPosts, getSeriesAuthors, getAuthorSlug } from '@/lib/markdown';
2
+ import PostList from '@/components/PostList';
3
+ import SeriesCatalog from '@/components/SeriesCatalog';
4
+ import Pagination from '@/components/Pagination';
5
+ import { siteConfig } from '../../../../../site.config';
6
+ import { Metadata } from 'next';
7
+ import { notFound } from 'next/navigation';
8
+ import { t, resolveLocale } from '@/lib/i18n';
9
+ import PageHeader from '@/components/PageHeader';
10
+ import CoverImage from '@/components/CoverImage';
11
+ import Link from 'next/link';
12
+ import { getPostsBasePath, getSeriesCustomPaths } from '@/lib/urls';
13
+
14
+ const POST_PAGE_SIZE = siteConfig.pagination.posts;
15
+ const SERIES_PAGE_SIZE = siteConfig.pagination.series;
16
+
17
+ export async function generateStaticParams() {
18
+ const params: { slug: string; page: string }[] = [];
19
+
20
+ // Custom posts basePath — paginated listing pages (page 2+)
21
+ const basePath = getPostsBasePath();
22
+ if (basePath !== 'posts') {
23
+ const allPosts = getListingPosts();
24
+ const totalPages = Math.ceil(allPosts.length / POST_PAGE_SIZE);
25
+ for (let i = 2; i <= totalPages; i++) {
26
+ params.push({ slug: basePath, page: i.toString() });
27
+ }
28
+ }
29
+
30
+ // Series custom paths — paginated series listing (page 2+)
31
+ for (const [seriesSlug, customPath] of Object.entries(getSeriesCustomPaths())) {
32
+ const posts = getSeriesPosts(seriesSlug);
33
+ const totalPages = Math.ceil(posts.length / SERIES_PAGE_SIZE);
34
+ for (let i = 2; i <= totalPages; i++) {
35
+ params.push({ slug: customPath, page: i.toString() });
36
+ }
37
+ }
38
+
39
+ // Placeholder keeps Next.js happy with output: export when no custom paths configured.
40
+ // dynamicParams = false ensures any unrecognised slug/page combo returns 404.
41
+ return params.length > 0 ? params : [{ slug: '_', page: '2' }];
42
+ }
43
+
44
+ export const dynamicParams = false;
45
+
46
+ export async function generateMetadata({
47
+ params,
48
+ }: {
49
+ params: Promise<{ slug: string; page: string }>;
50
+ }): Promise<Metadata> {
51
+ const { slug: prefix, page } = await params;
52
+ const basePath = getPostsBasePath();
53
+ const customPaths = getSeriesCustomPaths();
54
+ const matchedSeriesSlug = Object.entries(customPaths).find(([, path]) => path === prefix)?.[0];
55
+
56
+ if (prefix === basePath && basePath !== 'posts') {
57
+ return {
58
+ title: `${t('posts')} - ${page} | ${resolveLocale(siteConfig.title)}`,
59
+ };
60
+ }
61
+
62
+ if (matchedSeriesSlug) {
63
+ const seriesData = getSeriesData(matchedSeriesSlug);
64
+ const title = seriesData?.title || matchedSeriesSlug;
65
+ return {
66
+ title: `${title} - ${page} | ${resolveLocale(siteConfig.title)}`,
67
+ };
68
+ }
69
+
70
+ return { title: 'Not Found' };
71
+ }
72
+
73
+ export default async function PrefixPageRoute({
74
+ params,
75
+ }: {
76
+ params: Promise<{ slug: string; page: string }>;
77
+ }) {
78
+ const { slug: prefix, page: pageStr } = await params;
79
+ const page = parseInt(pageStr, 10);
80
+
81
+ if (isNaN(page) || page < 2) notFound();
82
+
83
+ const basePath = getPostsBasePath();
84
+ const customPaths = getSeriesCustomPaths();
85
+ const matchedSeriesSlug = Object.entries(customPaths).find(([, path]) => path === prefix)?.[0];
86
+
87
+ // Custom posts basePath listing
88
+ if (prefix === basePath && basePath !== 'posts') {
89
+ const allPosts = getListingPosts();
90
+ const totalPages = Math.ceil(allPosts.length / POST_PAGE_SIZE);
91
+
92
+ if (page > totalPages) notFound();
93
+
94
+ const start = (page - 1) * POST_PAGE_SIZE;
95
+ const posts = allPosts.slice(start, start + POST_PAGE_SIZE);
96
+
97
+ return (
98
+ <div className="layout-main">
99
+ <PageHeader
100
+ titleKey="posts"
101
+ subtitleKey="page_of_total"
102
+ subtitleParams={{ page, total: totalPages }}
103
+ className="mb-12"
104
+ />
105
+ <PostList posts={posts} />
106
+ <div className="mt-12">
107
+ <Pagination currentPage={page} totalPages={totalPages} basePath={`/${basePath}`} />
108
+ </div>
109
+ </div>
110
+ );
111
+ }
112
+
113
+ // Series custom path listing
114
+ if (matchedSeriesSlug) {
115
+ const seriesData = getSeriesData(matchedSeriesSlug);
116
+ const allPosts = getSeriesPosts(matchedSeriesSlug);
117
+
118
+ if ((!seriesData && allPosts.length === 0) || (process.env.NODE_ENV === 'production' && seriesData?.draft)) {
119
+ notFound();
120
+ }
121
+
122
+ const totalPages = Math.ceil(allPosts.length / SERIES_PAGE_SIZE);
123
+ if (page > totalPages) notFound();
124
+
125
+ const start = (page - 1) * SERIES_PAGE_SIZE;
126
+ const posts = allPosts.slice(start, start + SERIES_PAGE_SIZE);
127
+
128
+ const title = seriesData?.title || matchedSeriesSlug.charAt(0).toUpperCase() + matchedSeriesSlug.slice(1);
129
+ const description = seriesData?.excerpt;
130
+ const coverImage = seriesData?.coverImage;
131
+
132
+ const explicitAuthors = getSeriesAuthors(matchedSeriesSlug);
133
+ let authors: string[];
134
+ if (explicitAuthors) {
135
+ authors = explicitAuthors;
136
+ } else if (allPosts.length > 0) {
137
+ const counts = new Map<string, number>();
138
+ for (const post of allPosts) {
139
+ for (const author of post.authors) {
140
+ counts.set(author, (counts.get(author) || 0) + 1);
141
+ }
142
+ }
143
+ authors = [...counts.entries()].sort((a, b) => b[1] - a[1]).map(([name]) => name);
144
+ } else {
145
+ authors = [];
146
+ }
147
+
148
+ return (
149
+ <div className="layout-main">
150
+ <header className="mb-16">
151
+ {coverImage && (
152
+ <div className="relative w-full h-56 md:h-72 mb-10 rounded-2xl overflow-hidden shadow-xl shadow-accent/5">
153
+ <CoverImage
154
+ src={coverImage}
155
+ title={title}
156
+ slug={matchedSeriesSlug}
157
+ className="w-full h-full object-cover"
158
+ />
159
+ <div className="absolute inset-0 bg-gradient-to-t from-background/60 to-transparent" />
160
+ </div>
161
+ )}
162
+ <div className="text-center max-w-2xl mx-auto">
163
+ <span className="badge-accent mb-4">
164
+ {t('series')} • {allPosts.length} {t('parts')}
165
+ </span>
166
+ <h1 className="page-title mb-4">
167
+ {title}
168
+ <span className="block text-lg text-muted font-sans font-normal mt-2">
169
+ {page} / {totalPages}
170
+ </span>
171
+ </h1>
172
+ {description && (
173
+ <p className="text-lg text-muted font-serif italic leading-relaxed">{description}</p>
174
+ )}
175
+ {authors.length > 0 && (
176
+ <p className="mt-4 text-sm text-muted">
177
+ <span className="mr-1">{t('written_by')}</span>
178
+ {authors.map((author, index) => (
179
+ <span key={author}>
180
+ <Link
181
+ href={`/authors/${getAuthorSlug(author)}`}
182
+ className="text-foreground hover:text-accent no-underline transition-colors duration-200"
183
+ >
184
+ {author}
185
+ </Link>
186
+ {index < authors.length - 1 && <span className="mr-1">,</span>}
187
+ </span>
188
+ ))}
189
+ </p>
190
+ )}
191
+ </div>
192
+ </header>
193
+ <SeriesCatalog posts={posts} startIndex={start} totalPosts={allPosts.length} />
194
+ <div className="mt-12">
195
+ <Pagination currentPage={page} totalPages={totalPages} basePath={`/${prefix}`} />
196
+ </div>
197
+ </div>
198
+ );
199
+ }
200
+
201
+ notFound();
202
+ }
@@ -1,19 +1,41 @@
1
- import { getPageBySlug, getAllPages } from '@/lib/markdown';
1
+ import { getPageBySlug, getAllPages, 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';
5
+ import PostList from '@/components/PostList';
6
+ import SeriesCatalog from '@/components/SeriesCatalog';
7
+ import Pagination from '@/components/Pagination';
8
+ import CoverImage from '@/components/CoverImage';
9
+ import Link from 'next/link';
5
10
  import { Metadata } from 'next';
6
11
  import { siteConfig } from '../../../site.config';
7
- import { resolveLocale } from '@/lib/i18n';
12
+ import { resolveLocale, t } from '@/lib/i18n';
13
+ import PageHeader from '@/components/PageHeader';
14
+ import { getPostsBasePath, getSeriesCustomPaths } from '@/lib/urls';
15
+
16
+ const POST_PAGE_SIZE = siteConfig.pagination.posts;
17
+ const SERIES_PAGE_SIZE = siteConfig.pagination.series;
8
18
 
9
19
  /**
10
- * Generates the static paths for all top-level pages at build time.
20
+ * Generates the static paths for all top-level pages at build time,
21
+ * plus any custom URL prefixes configured for posts or series.
11
22
  */
12
23
  export async function generateStaticParams() {
13
24
  const pages = getAllPages();
14
- return pages.map((page) => ({
15
- slug: page.slug,
16
- }));
25
+ const params = pages.map((page) => ({ slug: page.slug }));
26
+
27
+ // Add custom posts basePath listing (e.g. /articles)
28
+ const basePath = getPostsBasePath();
29
+ if (basePath !== 'posts') {
30
+ params.push({ slug: basePath });
31
+ }
32
+
33
+ // Add series custom path listings (e.g. /weeklies)
34
+ for (const customPath of Object.values(getSeriesCustomPaths())) {
35
+ params.push({ slug: customPath });
36
+ }
37
+
38
+ return params;
17
39
  }
18
40
 
19
41
  export const dynamicParams = false;
@@ -21,8 +43,30 @@ export const dynamicParams = false;
21
43
  export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise<Metadata> {
22
44
  const { slug: rawSlug } = await params;
23
45
  const slug = decodeURIComponent(rawSlug);
24
- const page = getPageBySlug(slug);
25
46
 
47
+ // Custom posts basePath
48
+ const basePath = getPostsBasePath();
49
+ if (slug === basePath && basePath !== 'posts') {
50
+ return {
51
+ title: `${t('posts')} | ${resolveLocale(siteConfig.title)}`,
52
+ description: t('posts_description'),
53
+ };
54
+ }
55
+
56
+ // Series custom paths
57
+ const customPaths = getSeriesCustomPaths();
58
+ const matchedSeriesSlug = Object.entries(customPaths).find(([, path]) => path === slug)?.[0];
59
+ if (matchedSeriesSlug) {
60
+ const seriesData = getSeriesData(matchedSeriesSlug);
61
+ if (seriesData) {
62
+ return {
63
+ title: `${seriesData.title} - ${t('series')} | ${resolveLocale(siteConfig.title)}`,
64
+ description: seriesData.excerpt,
65
+ };
66
+ }
67
+ }
68
+
69
+ const page = getPageBySlug(slug);
26
70
  if (!page) {
27
71
  return { title: 'Page Not Found' };
28
72
  }
@@ -40,6 +84,117 @@ export default async function Page({
40
84
  }) {
41
85
  const { slug: rawSlug } = await params;
42
86
  const slug = decodeURIComponent(rawSlug);
87
+
88
+ // Check if slug matches custom posts basePath
89
+ const basePath = getPostsBasePath();
90
+ if (slug === basePath && basePath !== 'posts') {
91
+ const allPosts = getListingPosts();
92
+ const totalPages = Math.ceil(allPosts.length / POST_PAGE_SIZE);
93
+ const posts = allPosts.slice(0, POST_PAGE_SIZE);
94
+
95
+ return (
96
+ <div className="layout-main">
97
+ <PageHeader
98
+ titleKey="posts"
99
+ subtitleKey="posts_subtitle"
100
+ subtitleParams={{ count: allPosts.length }}
101
+ className="mb-12"
102
+ />
103
+ <PostList posts={posts} />
104
+ {totalPages > 1 && (
105
+ <div className="mt-12">
106
+ <Pagination currentPage={1} totalPages={totalPages} basePath={`/${basePath}`} />
107
+ </div>
108
+ )}
109
+ </div>
110
+ );
111
+ }
112
+
113
+ // Check if slug matches a series custom path
114
+ const customPaths = getSeriesCustomPaths();
115
+ const matchedSeriesSlug = Object.entries(customPaths).find(([, path]) => path === slug)?.[0];
116
+ if (matchedSeriesSlug) {
117
+ const seriesData = getSeriesData(matchedSeriesSlug);
118
+ const allPosts = getSeriesPosts(matchedSeriesSlug);
119
+
120
+ if ((!seriesData && allPosts.length === 0) || (process.env.NODE_ENV === 'production' && seriesData?.draft)) {
121
+ notFound();
122
+ }
123
+
124
+ const totalPages = Math.ceil(allPosts.length / SERIES_PAGE_SIZE);
125
+ const posts = allPosts.slice(0, SERIES_PAGE_SIZE);
126
+
127
+ const title = seriesData?.title || matchedSeriesSlug.charAt(0).toUpperCase() + matchedSeriesSlug.slice(1);
128
+ const description = seriesData?.excerpt;
129
+ const coverImage = seriesData?.coverImage;
130
+
131
+ const explicitAuthors = getSeriesAuthors(matchedSeriesSlug);
132
+ let authors: string[];
133
+ if (explicitAuthors) {
134
+ authors = explicitAuthors;
135
+ } else if (allPosts.length > 0) {
136
+ const counts = new Map<string, number>();
137
+ for (const post of allPosts) {
138
+ for (const author of post.authors) {
139
+ counts.set(author, (counts.get(author) || 0) + 1);
140
+ }
141
+ }
142
+ authors = [...counts.entries()].sort((a, b) => b[1] - a[1]).map(([name]) => name);
143
+ } else {
144
+ authors = [];
145
+ }
146
+
147
+ return (
148
+ <div className="layout-main">
149
+ <header className="mb-16">
150
+ {coverImage && (
151
+ <div className="relative w-full h-56 md:h-72 mb-10 rounded-2xl overflow-hidden shadow-xl shadow-accent/5">
152
+ <CoverImage
153
+ src={coverImage}
154
+ title={title}
155
+ slug={matchedSeriesSlug}
156
+ className="w-full h-full object-cover"
157
+ />
158
+ <div className="absolute inset-0 bg-gradient-to-t from-background/60 to-transparent" />
159
+ </div>
160
+ )}
161
+ <div className="text-center max-w-2xl mx-auto">
162
+ <span className="badge-accent mb-4">
163
+ {t('series')} • {allPosts.length} {t('parts')}
164
+ </span>
165
+ <h1 className="page-title mb-4">{title}</h1>
166
+ {description && (
167
+ <p className="text-lg text-muted font-serif italic leading-relaxed">{description}</p>
168
+ )}
169
+ {authors.length > 0 && (
170
+ <p className="mt-4 text-sm text-muted">
171
+ <span className="mr-1">{t('written_by')}</span>
172
+ {authors.map((author, index) => (
173
+ <span key={author}>
174
+ <Link
175
+ href={`/authors/${getAuthorSlug(author)}`}
176
+ className="text-foreground hover:text-accent no-underline transition-colors duration-200"
177
+ >
178
+ {author}
179
+ </Link>
180
+ {index < authors.length - 1 && <span className="mr-1">,</span>}
181
+ </span>
182
+ ))}
183
+ </p>
184
+ )}
185
+ </div>
186
+ </header>
187
+ <SeriesCatalog posts={posts} totalPosts={allPosts.length} />
188
+ {totalPages > 1 && (
189
+ <div className="mt-12">
190
+ <Pagination currentPage={1} totalPages={totalPages} basePath={`/${slug}`} />
191
+ </div>
192
+ )}
193
+ </div>
194
+ );
195
+ }
196
+
197
+ // Default: static page
43
198
  const page = getPageBySlug(slug);
44
199
 
45
200
  if (!page) {
@@ -3,10 +3,11 @@ import { getAllPosts, getSeriesData, PostData } from '@/lib/markdown';
3
3
  import { siteConfig } from '../../../site.config';
4
4
  import { resolveLocale, t } from '@/lib/i18n';
5
5
  import PageHeader from '@/components/PageHeader';
6
+ import { getPostUrl } from '@/lib/urls';
6
7
 
7
8
  export const metadata = {
8
9
  title: `Archive | ${resolveLocale(siteConfig.title)}`,
9
- description: 'A complete list of all notes and thoughts.',
10
+ description: t('archive_description'),
10
11
  };
11
12
 
12
13
  // Use month number as key for reliable sorting across all locales
@@ -22,9 +23,7 @@ function groupPostsByDate(posts: PostData[]): GroupedPosts {
22
23
  const groups: GroupedPosts = {};
23
24
 
24
25
  posts.forEach((post) => {
25
- const date = new Date(post.date);
26
- const year = date.getFullYear().toString();
27
- const month = (date.getMonth() + 1).toString();
26
+ const [year, month] = post.date.split('-');
28
27
 
29
28
  if (!groups[year]) {
30
29
  groups[year] = {};
@@ -124,12 +123,10 @@ export default function ArchivePage() {
124
123
 
125
124
  <ul className="space-y-6">
126
125
  {monthPosts.map((post) => {
127
- const dateObj = new Date(post.date);
128
- const day = dateObj.getDate().toString().padStart(2, '0');
129
-
126
+ const day = post.date.split('-')[2];
130
127
  return (
131
128
  <li key={post.slug} className="group">
132
- <Link href={`/posts/${post.slug}`} className="block no-underline">
129
+ <Link href={getPostUrl(post)} className="block no-underline">
133
130
  <div className="flex flex-col sm:flex-row sm:items-baseline justify-between gap-2 sm:gap-6">
134
131
  <div className="flex items-baseline gap-6">
135
132
  <span className="font-mono text-base text-muted shrink-0 w-8">
@@ -4,6 +4,8 @@ import { Metadata } from 'next';
4
4
  import { siteConfig } from '../../../../../site.config';
5
5
  import BookLayout from '@/layouts/BookLayout';
6
6
  import { resolveLocale } from '@/lib/i18n';
7
+ import { buildBookChapterJsonLd, serializeJsonLd } from '@/lib/json-ld';
8
+ import { getBookUrl, getBookChapterUrl } from '@/lib/urls';
7
9
 
8
10
  export async function generateStaticParams() {
9
11
  const books = getAllBooks();
@@ -12,11 +14,18 @@ export async function generateStaticParams() {
12
14
 
13
15
  for (const book of books) {
14
16
  for (const ch of book.chapters) {
15
- params.push({ slug: book.slug, chapter: ch.id });
17
+ // Only include chapters whose files exist and parse successfully.
18
+ // A chapter listed in index.mdx but not yet written (or with invalid
19
+ // frontmatter) would cause notFound() at render time, which in
20
+ // output:export dev mode surfaces as a confusing "missing param" 500.
21
+ if (getBookChapter(book.slug, ch.id) !== null) {
22
+ params.push({ slug: book.slug, chapter: ch.id });
23
+ }
16
24
  }
17
25
  }
18
26
 
19
- return params;
27
+ // Ensure we never return an empty array with output: export
28
+ return params.length > 0 ? params : [{ slug: '_', chapter: '_' }];
20
29
  }
21
30
 
22
31
  export const dynamicParams = false;
@@ -33,9 +42,27 @@ export async function generateMetadata({ params }: { params: Promise<{ slug: str
33
42
  return { title: 'Chapter Not Found' };
34
43
  }
35
44
 
45
+ const ogImage = book.coverImage && !book.coverImage.startsWith('text:') && !book.coverImage.startsWith('./')
46
+ ? book.coverImage
47
+ : siteConfig.ogImage;
48
+
36
49
  return {
37
50
  title: `${chapter.title} - ${book.title} | ${resolveLocale(siteConfig.title)}`,
38
51
  description: chapter.excerpt,
52
+ openGraph: {
53
+ title: `${chapter.title} - ${book.title}`,
54
+ description: chapter.excerpt,
55
+ type: 'article',
56
+ url: `${siteConfig.baseUrl}${getBookChapterUrl(slug, chapterSlug)}`,
57
+ siteName: resolveLocale(siteConfig.title),
58
+ images: [{ url: ogImage, width: 1200, height: 630, alt: chapter.title }],
59
+ },
60
+ twitter: {
61
+ card: ogImage !== siteConfig.ogImage ? 'summary_large_image' : 'summary',
62
+ title: `${chapter.title} - ${book.title}`,
63
+ description: chapter.excerpt,
64
+ images: [ogImage],
65
+ },
39
66
  };
40
67
  }
41
68
 
@@ -51,5 +78,20 @@ export default async function BookChapterPage({ params }: { params: Promise<{ sl
51
78
  notFound();
52
79
  }
53
80
 
54
- return <BookLayout book={book} chapter={chapter} />;
81
+ const siteUrl = siteConfig.baseUrl.replace(/\/+$/, '');
82
+ const jsonLd = buildBookChapterJsonLd({
83
+ chapter,
84
+ book,
85
+ chapterUrl: `${siteUrl}${getBookChapterUrl(slug, chapterSlug)}`,
86
+ bookUrl: `${siteUrl}${getBookUrl(slug)}`,
87
+ siteTitle: resolveLocale(siteConfig.title),
88
+ siteUrl,
89
+ });
90
+
91
+ return (
92
+ <>
93
+ <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: serializeJsonLd(jsonLd) }} />
94
+ <BookLayout book={book} chapter={chapter} />
95
+ </>
96
+ );
55
97
  }