@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.
- package/.entire/settings.json +4 -0
- package/AGENTS.md +13 -7
- package/CHANGELOG.md +41 -0
- package/CLAUDE.md +30 -2
- package/README.md +84 -58
- package/README.zh.md +195 -0
- package/TODO.md +6 -4
- package/bun.lock +30 -4
- package/content/books/sample-book/images/digital-garden.svg +25 -0
- package/content/books/sample-book/index.mdx +1 -1
- package/content/books/sample-book/introduction.mdx +2 -0
- package/content/posts/2026-01-21-kitchen-sink/index.mdx +26 -1
- package/content/posts/legacy-markdown.md +0 -2
- package/content/posts/markdown-features.mdx +1 -1
- package/content/posts/multilingual-test.mdx +0 -1
- package/content/posts/syntax-highlighting-showcase.mdx +1 -0
- package/content/posts/understanding-react-hooks.mdx +1 -0
- package/content/series/ai-nexus-weekly/index.mdx +1 -1
- package/content/series/digital-garden/01-philosophy.mdx +2 -0
- package/content/series/digital-garden/index.mdx +1 -1
- package/content/series/markdown-showcase/index.mdx +1 -1
- package/content/series/markdown-showcase/syntax-highlighting.mdx +2 -0
- package/content/series/nextjs-deep-dive/01-getting-started.mdx +2 -1
- package/content/series/nextjs-deep-dive/index.mdx +1 -1
- package/content/subscribe.mdx +27 -0
- package/docs/ARCHITECTURE.md +85 -105
- package/docs/CONTRIBUTING.md +11 -3
- package/docs/DIGITAL_GARDEN.md +11 -9
- package/docs/deployment.md +31 -13
- package/eslint.config.mjs +2 -0
- package/package.json +13 -4
- package/packages/create-amytis/package.json +13 -0
- package/packages/create-amytis/src/index.test.ts +187 -0
- package/packages/create-amytis/src/index.ts +230 -0
- package/packages/create-amytis/tsconfig.json +14 -0
- package/public/images/amytis-screenshot.jpg +0 -0
- package/public/images/antelope-canyon.jpg +0 -0
- package/public/images/avatar.jpg +0 -0
- package/public/images/cappadocia.jpg +0 -0
- package/public/images/flowers.jpg +0 -0
- package/public/images/galaxy.jpg +0 -0
- package/public/images/lake.jpg +0 -0
- package/public/images/mountains.jpg +0 -0
- package/public/images/vibrant-waves.avif +0 -0
- package/public/images/wechat-qr.jpg +0 -0
- package/public/next-image-export-optimizer-hashes.json +72 -2
- package/scripts/deploy.ts +77 -0
- package/site.config.example.ts +268 -0
- package/site.config.ts +82 -10
- package/src/app/[slug]/[postSlug]/page.tsx +161 -0
- package/src/app/[slug]/page/[page]/page.tsx +202 -0
- package/src/app/[slug]/page.tsx +162 -7
- package/src/app/archive/page.tsx +5 -8
- package/src/app/books/[slug]/[chapter]/page.tsx +45 -3
- package/src/app/books/[slug]/page.tsx +16 -2
- package/src/app/books/page.tsx +12 -8
- package/src/app/feed.atom/route.ts +62 -0
- package/src/app/feed.xml/route.ts +45 -20
- package/src/app/flows/[year]/[month]/[day]/page.tsx +9 -1
- package/src/app/flows/[year]/page.tsx +1 -1
- package/src/app/flows/page/[page]/page.tsx +2 -0
- package/src/app/flows/page.tsx +4 -0
- package/src/app/globals.css +9 -3
- package/src/app/layout.tsx +22 -1
- package/src/app/notes/[slug]/page.tsx +2 -3
- package/src/app/page.tsx +86 -8
- package/src/app/posts/[slug]/page.tsx +33 -7
- package/src/app/posts/page/[page]/page.tsx +9 -5
- package/src/app/posts/page.tsx +13 -7
- package/src/app/series/[slug]/page/[page]/page.tsx +8 -25
- package/src/app/series/[slug]/page.tsx +21 -19
- package/src/app/series/page.tsx +14 -28
- package/src/app/sitemap.ts +2 -1
- package/src/app/tags/[tag]/page.tsx +1 -1
- package/src/app/tags/page.tsx +1 -1
- package/src/components/Analytics.tsx +34 -36
- package/src/components/AuthorCard.tsx +56 -14
- package/src/components/BookMobileNav.tsx +8 -34
- package/src/components/BookSidebar.tsx +17 -107
- package/src/components/CodeBlock.test.tsx +19 -0
- package/src/components/CodeBlock.tsx +20 -16
- package/src/components/Comments.tsx +4 -3
- package/src/components/CoverImage.tsx +11 -3
- package/src/components/CuratedSeriesSection.tsx +17 -15
- package/src/components/ExternalLinks.tsx +1 -1
- package/src/components/FeaturedStoriesSection.tsx +124 -58
- package/src/components/FlowCalendarSidebar.tsx +10 -7
- package/src/components/FlowContent.tsx +33 -5
- package/src/components/Footer.tsx +28 -6
- package/src/components/Hero.tsx +44 -92
- package/src/components/InlineBookToc.tsx +56 -0
- package/src/components/KnowledgeGraph.tsx +26 -0
- package/src/components/LanguageProvider.tsx +7 -2
- package/src/components/LanguageSwitch.tsx +1 -1
- package/src/components/LatestWritingSection.tsx +12 -22
- package/src/components/MarkdownRenderer.test.tsx +41 -0
- package/src/components/MarkdownRenderer.tsx +43 -8
- package/src/components/Navbar.tsx +24 -15
- package/src/components/NoteSidebar.tsx +6 -76
- package/src/components/Pagination.tsx +2 -2
- package/src/components/PostCard.tsx +2 -1
- package/src/components/PostList.tsx +81 -62
- package/src/components/PostNavigation.tsx +8 -7
- package/src/components/PostSidebar.tsx +12 -107
- package/src/components/PrevNextNav.tsx +63 -0
- package/src/components/RecentNotesSection.tsx +2 -2
- package/src/components/RelatedPosts.tsx +3 -2
- package/src/components/RssFeedWidget.tsx +55 -0
- package/src/components/SectionLabel.tsx +16 -0
- package/src/components/SelectedBooksSection.tsx +12 -15
- package/src/components/SeriesCatalog.tsx +74 -69
- package/src/components/SeriesList.tsx +11 -36
- package/src/components/SeriesSidebar.tsx +4 -2
- package/src/components/TagContentTabs.tsx +17 -12
- package/src/components/TagPageHeader.tsx +12 -2
- package/src/components/TagSidebar.tsx +3 -2
- package/src/components/TagsIndexClient.tsx +1 -1
- package/src/components/TocPanel.tsx +67 -0
- package/src/hooks/useActiveHeading.ts +32 -0
- package/src/hooks/useSidebarAutoScroll.ts +19 -0
- package/src/i18n/translations.ts +26 -4
- package/src/layouts/BookLayout.tsx +10 -40
- package/src/layouts/PostLayout.tsx +37 -26
- package/src/layouts/SimpleLayout.tsx +5 -5
- package/src/lib/feed-utils.ts +68 -0
- package/src/lib/format-utils.test.ts +27 -0
- package/src/lib/format-utils.ts +3 -0
- package/src/lib/image-utils.ts +12 -0
- package/src/lib/json-ld.ts +236 -0
- package/src/lib/markdown.ts +62 -7
- package/src/lib/rehype-image-metadata.test.ts +124 -0
- package/src/lib/rehype-image-metadata.ts +21 -5
- package/src/lib/scroll-utils.ts +11 -0
- package/src/lib/shuffle.ts +21 -0
- package/src/lib/urls.ts +51 -0
- package/tests/e2e/navigation.test.ts +15 -1
- package/tests/integration/books.test.ts +61 -0
- package/tests/integration/feed-utils.test.ts +145 -0
- package/tests/integration/series.test.ts +9 -0
- package/tests/unit/static-params.test.ts +59 -2
- package/src/app/subscribe/page.tsx +0 -17
- package/src/components/SubscribePage.tsx +0 -298
- /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
|
+
}
|
package/src/app/[slug]/page.tsx
CHANGED
|
@@ -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
|
-
|
|
15
|
-
|
|
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) {
|
package/src/app/archive/page.tsx
CHANGED
|
@@ -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: '
|
|
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
|
|
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
|
|
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={
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
}
|