@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.
- package/CHANGELOG.md +29 -0
- package/GEMINI.md +9 -1
- package/README.md +26 -17
- package/README.zh.md +180 -100
- package/bun.lock +78 -74
- package/content/books/notes-on-thinking/cost-of-certainty.mdx +9 -0
- package/content/books/notes-on-thinking/index.mdx +16 -0
- package/content/books/notes-on-thinking/mental-models.mdx +9 -0
- package/content/books/the-pragmatic-writer/finding-your-voice.mdx +9 -0
- package/content/books/the-pragmatic-writer/index.mdx +18 -0
- package/content/books/the-pragmatic-writer/the-editing-loop.mdx +9 -0
- package/content/books/the-pragmatic-writer/why-writing-matters.mdx +9 -0
- package/content/flows/2026/03/01.md +9 -0
- package/content/flows/2026/03/03.md +9 -0
- package/content/flows/2026/03/05.md +10 -0
- package/content/flows/2026/03/07.md +11 -0
- package/content/posts/images/vibrant-waves.jpg +0 -0
- package/content/posts/welcome-to-amytis.mdx +3 -0
- package/content/series/markdown-showcase/index.mdx +2 -1
- package/content/series/markdown-showcase/mathematical-notation.mdx +8 -4
- package/content/series/markdown-showcase/syntax-highlighting.mdx +9 -5
- package/content/series/markdown-showcase/visuals-and-diagrams.mdx +8 -4
- 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
- package/content/series/modern-web-dev/index.mdx +4 -2
- package/docs/ARCHITECTURE.md +8 -1
- package/docs/DIGITAL_GARDEN.md +22 -1
- package/package.json +12 -12
- package/public/next-image-export-optimizer-hashes.json +3 -2
- package/scripts/new-flow.ts +1 -0
- package/site.config.example.ts +3 -4
- package/site.config.ts +6 -7
- package/src/app/[slug]/[postSlug]/page.tsx +19 -2
- package/src/app/[slug]/page/[page]/page.tsx +26 -5
- package/src/app/[slug]/page.tsx +28 -8
- package/src/app/all.atom/route.ts +7 -0
- package/src/app/all.xml/route.ts +7 -0
- package/src/app/archive/page.tsx +7 -4
- package/src/app/feed.atom/route.ts +2 -57
- package/src/app/feed.xml/route.ts +2 -64
- package/src/app/flows/[year]/[month]/[day]/page.tsx +13 -0
- package/src/app/flows/feed.atom/route.ts +7 -0
- package/src/app/flows/feed.xml/route.ts +7 -0
- package/src/app/page.tsx +1 -2
- package/src/app/posts/[slug]/page.tsx +28 -9
- package/src/app/posts/feed.atom/route.ts +9 -0
- package/src/app/posts/feed.xml/route.ts +9 -0
- package/src/app/series/[slug]/page.tsx +46 -4
- package/src/components/CuratedSeriesSection.tsx +7 -11
- package/src/components/FeaturedStoriesSection.tsx +1 -1
- package/src/components/FlowCalendarSidebar.tsx +1 -1
- package/src/components/FlowContent.tsx +2 -1
- package/src/components/FlowTimelineEntry.tsx +7 -1
- package/src/components/Footer.tsx +6 -6
- package/src/components/HorizontalScroll.tsx +5 -14
- package/src/components/MarkdownRenderer.test.tsx +6 -0
- package/src/components/MarkdownRenderer.tsx +18 -16
- package/src/components/Navbar.tsx +1 -1
- package/src/components/PostList.tsx +20 -36
- package/src/components/PostSidebar.tsx +1 -1
- package/src/components/RecentNotesSection.tsx +4 -0
- package/src/components/SelectedBooksSection.tsx +65 -25
- package/src/components/SeriesCatalog.tsx +9 -7
- package/src/i18n/translations.ts +2 -0
- package/src/layouts/PostLayout.tsx +1 -1
- package/src/layouts/SimpleLayout.tsx +3 -3
- package/src/lib/feed-utils.ts +158 -18
- package/src/lib/markdown.ts +26 -5
- package/src/lib/urls.ts +9 -4
- package/tests/e2e/mobile/mobile-compat.spec.ts +58 -0
- package/tests/e2e/navigation.test.ts +26 -0
- package/tests/integration/collections.test.ts +17 -2
- package/tests/integration/feed-utils.test.ts +52 -0
- package/tests/integration/flow-title.test.ts +53 -0
- package/tests/integration/markdown-features.test.ts +3 -3
- package/tests/integration/reading-time-headings.test.ts +2 -2
- package/tests/unit/static-params.test.ts +155 -22
- package/tests/unit/urls.test.ts +10 -12
- /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
|
@@ -1,62 +1,7 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { resolveLocale } from '@/lib/i18n';
|
|
3
|
-
import { getFeedItems } from '@/lib/feed-utils';
|
|
1
|
+
import { generateAtomFeed } from '@/lib/feed-utils';
|
|
4
2
|
|
|
5
3
|
export const dynamic = 'force-static';
|
|
6
4
|
|
|
7
|
-
const escapeXml = (v: string) =>
|
|
8
|
-
v.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
9
|
-
.replace(/"/g, '"').replace(/'/g, ''');
|
|
10
|
-
|
|
11
|
-
const escapeCdata = (v: string) => v.replace(/]]>/g, ']]]]><![CDATA[>');
|
|
12
|
-
|
|
13
5
|
export async function GET() {
|
|
14
|
-
|
|
15
|
-
if (format === 'rss') {
|
|
16
|
-
return new Response('Not Found', { status: 404 });
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
const baseUrl = siteConfig.baseUrl.replace(/\/+$/, '');
|
|
20
|
-
const items = getFeedItems();
|
|
21
|
-
const useFullContent = contentMode === 'full';
|
|
22
|
-
const feedUpdated = items[0]?.date.toISOString() ?? new Date().toISOString();
|
|
23
|
-
|
|
24
|
-
const entriesXml = items
|
|
25
|
-
.map((item) => {
|
|
26
|
-
const contentXml = useFullContent
|
|
27
|
-
? `<content type="html"><![CDATA[${escapeCdata(item.content)}]]></content>\n <summary><![CDATA[${escapeCdata(item.excerpt)}]]></summary>`
|
|
28
|
-
: `<summary><![CDATA[${escapeCdata(item.excerpt)}]]></summary>`;
|
|
29
|
-
const authorsXml = item.authors?.map((a) => `<author><name>${escapeXml(a)}</name></author>`).join('') ?? '';
|
|
30
|
-
const categoriesXml = item.tags.map((tag) => `<category term="${escapeXml(tag)}" />`).join('');
|
|
31
|
-
return `
|
|
32
|
-
<entry>
|
|
33
|
-
<title><![CDATA[${escapeCdata(item.title)}]]></title>
|
|
34
|
-
<link href="${escapeXml(item.url)}" />
|
|
35
|
-
<id>${escapeXml(item.url)}</id>
|
|
36
|
-
<published>${item.date.toISOString()}</published>
|
|
37
|
-
<updated>${item.date.toISOString()}</updated>
|
|
38
|
-
${contentXml}
|
|
39
|
-
${authorsXml}
|
|
40
|
-
${categoriesXml}
|
|
41
|
-
</entry>`;
|
|
42
|
-
})
|
|
43
|
-
.join('');
|
|
44
|
-
|
|
45
|
-
const atomXml = `<?xml version="1.0" encoding="UTF-8" ?>
|
|
46
|
-
<feed xmlns="http://www.w3.org/2005/Atom">
|
|
47
|
-
<title><![CDATA[${escapeCdata(resolveLocale(siteConfig.title))}]]></title>
|
|
48
|
-
<link href="${escapeXml(baseUrl)}" />
|
|
49
|
-
<link href="${escapeXml(baseUrl)}/feed.atom" rel="self" type="application/atom+xml" />
|
|
50
|
-
<id>${escapeXml(baseUrl)}/feed.atom</id>
|
|
51
|
-
<updated>${feedUpdated}</updated>
|
|
52
|
-
<subtitle><![CDATA[${escapeCdata(resolveLocale(siteConfig.description))}]]></subtitle>
|
|
53
|
-
${entriesXml}
|
|
54
|
-
</feed>`;
|
|
55
|
-
|
|
56
|
-
return new Response(atomXml, {
|
|
57
|
-
headers: {
|
|
58
|
-
'Content-Type': 'application/atom+xml; charset=utf-8',
|
|
59
|
-
'Cache-Control': 'public, max-age=3600',
|
|
60
|
-
},
|
|
61
|
-
});
|
|
6
|
+
return generateAtomFeed('main', '/feed.atom');
|
|
62
7
|
}
|
|
@@ -1,69 +1,7 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { resolveLocale } from '@/lib/i18n';
|
|
3
|
-
import { getFeedItems } from '@/lib/feed-utils';
|
|
1
|
+
import { generateRssFeed } from '@/lib/feed-utils';
|
|
4
2
|
|
|
5
3
|
export const dynamic = 'force-static';
|
|
6
4
|
|
|
7
|
-
const escapeXml = (v: string) =>
|
|
8
|
-
v.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
9
|
-
.replace(/"/g, '"').replace(/'/g, ''');
|
|
10
|
-
|
|
11
|
-
const escapeCdata = (v: string) => v.replace(/]]>/g, ']]]]><![CDATA[>');
|
|
12
|
-
|
|
13
5
|
export async function GET() {
|
|
14
|
-
|
|
15
|
-
if (format === 'atom') {
|
|
16
|
-
return new Response('Not Found', { status: 404 });
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
const baseUrl = siteConfig.baseUrl.replace(/\/+$/, '');
|
|
20
|
-
const items = getFeedItems();
|
|
21
|
-
const useFullContent = contentMode === 'full';
|
|
22
|
-
const contentNs = useFullContent ? ' xmlns:content="http://purl.org/rss/modules/content/"' : '';
|
|
23
|
-
const siteTitle = resolveLocale(siteConfig.title);
|
|
24
|
-
const lastBuildDate = items[0]?.date.toUTCString() ?? new Date().toUTCString();
|
|
25
|
-
|
|
26
|
-
const imageXml = siteConfig.ogImage
|
|
27
|
-
? `\n <image>\n <url>${escapeXml(baseUrl + siteConfig.ogImage)}</url>\n <title>${escapeXml(siteTitle)}</title>\n <link>${escapeXml(baseUrl)}</link>\n </image>`
|
|
28
|
-
: '';
|
|
29
|
-
|
|
30
|
-
const rssItemsXml = items
|
|
31
|
-
.map((item) => {
|
|
32
|
-
const fullContentXml = useFullContent
|
|
33
|
-
? `\n <content:encoded><![CDATA[${escapeCdata(item.content)}]]></content:encoded>`
|
|
34
|
-
: '';
|
|
35
|
-
const authorsXml = item.authors?.length
|
|
36
|
-
? item.authors.map((a) => `\n <dc:creator><![CDATA[${escapeCdata(a)}]]></dc:creator>`).join('')
|
|
37
|
-
: '';
|
|
38
|
-
return `
|
|
39
|
-
<item>
|
|
40
|
-
<title><![CDATA[${escapeCdata(item.title)}]]></title>
|
|
41
|
-
<link>${escapeXml(item.url)}</link>
|
|
42
|
-
<guid isPermaLink="true">${escapeXml(item.url)}</guid>
|
|
43
|
-
<pubDate>${item.date.toUTCString()}</pubDate>
|
|
44
|
-
<description><![CDATA[${escapeCdata(item.excerpt)}]]></description>${fullContentXml}${authorsXml}
|
|
45
|
-
${item.tags.map((tag) => `<category><![CDATA[${escapeCdata(tag)}]]></category>`).join('')}
|
|
46
|
-
</item>`;
|
|
47
|
-
})
|
|
48
|
-
.join('');
|
|
49
|
-
|
|
50
|
-
const rssXml = `<?xml version="1.0" encoding="UTF-8" ?>
|
|
51
|
-
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/"${contentNs}>
|
|
52
|
-
<channel>
|
|
53
|
-
<title><![CDATA[${escapeCdata(siteTitle)}]]></title>
|
|
54
|
-
<link>${escapeXml(baseUrl)}</link>
|
|
55
|
-
<description><![CDATA[${escapeCdata(resolveLocale(siteConfig.description))}]]></description>
|
|
56
|
-
<language>${siteConfig.i18n.defaultLocale}</language>
|
|
57
|
-
<lastBuildDate>${lastBuildDate}</lastBuildDate>
|
|
58
|
-
<atom:link href="${escapeXml(baseUrl)}/feed.xml" rel="self" type="application/rss+xml" />${imageXml}
|
|
59
|
-
${rssItemsXml}
|
|
60
|
-
</channel>
|
|
61
|
-
</rss>`;
|
|
62
|
-
|
|
63
|
-
return new Response(rssXml, {
|
|
64
|
-
headers: {
|
|
65
|
-
'Content-Type': 'application/rss+xml; charset=utf-8',
|
|
66
|
-
'Cache-Control': 'public, max-age=3600',
|
|
67
|
-
},
|
|
68
|
-
});
|
|
6
|
+
return generateRssFeed('main', '/feed.xml');
|
|
69
7
|
}
|
|
@@ -88,6 +88,9 @@ export default async function FlowPage({ params }: { params: Promise<{ year: str
|
|
|
88
88
|
{/* Header */}
|
|
89
89
|
<header className="mb-8">
|
|
90
90
|
<time className="text-base font-mono text-accent" data-pagefind-meta="date[content]">{flow.date}</time>
|
|
91
|
+
{flow.title !== flow.date && (
|
|
92
|
+
<h1 className="mt-2 text-xl sm:text-2xl font-serif font-bold text-heading">{flow.title}</h1>
|
|
93
|
+
)}
|
|
91
94
|
{flow.tags.length > 0 && (
|
|
92
95
|
<div className="mt-3 flex flex-wrap gap-2">
|
|
93
96
|
{flow.tags.map(tag => (
|
|
@@ -121,6 +124,11 @@ export default async function FlowPage({ params }: { params: Promise<{ year: str
|
|
|
121
124
|
<div className="text-sm font-mono text-heading group-hover:text-accent transition-colors">
|
|
122
125
|
{prev.date}
|
|
123
126
|
</div>
|
|
127
|
+
{prev.title !== prev.date && (
|
|
128
|
+
<div className="text-sm text-muted group-hover:text-accent/80 transition-colors truncate">
|
|
129
|
+
{prev.title}
|
|
130
|
+
</div>
|
|
131
|
+
)}
|
|
124
132
|
</Link>
|
|
125
133
|
) : <div />}
|
|
126
134
|
{next ? (
|
|
@@ -132,6 +140,11 @@ export default async function FlowPage({ params }: { params: Promise<{ year: str
|
|
|
132
140
|
<div className="text-sm font-mono text-heading group-hover:text-accent transition-colors">
|
|
133
141
|
{next.date}
|
|
134
142
|
</div>
|
|
143
|
+
{next.title !== next.date && (
|
|
144
|
+
<div className="text-sm text-muted group-hover:text-accent/80 transition-colors truncate">
|
|
145
|
+
{next.title}
|
|
146
|
+
</div>
|
|
147
|
+
)}
|
|
135
148
|
</Link>
|
|
136
149
|
) : <div />}
|
|
137
150
|
</nav>
|
package/src/app/page.tsx
CHANGED
|
@@ -34,7 +34,6 @@ type HomepageSection = {
|
|
|
34
34
|
enabled?: boolean;
|
|
35
35
|
weight: number;
|
|
36
36
|
maxItems?: number;
|
|
37
|
-
scrollThreshold?: number;
|
|
38
37
|
};
|
|
39
38
|
|
|
40
39
|
export default function Home() {
|
|
@@ -97,6 +96,7 @@ export default function Home() {
|
|
|
97
96
|
? recentFlows.map(f => ({
|
|
98
97
|
slug: f.slug,
|
|
99
98
|
date: f.date,
|
|
99
|
+
title: f.title,
|
|
100
100
|
excerpt: f.excerpt,
|
|
101
101
|
}))
|
|
102
102
|
: [];
|
|
@@ -133,7 +133,6 @@ export default function Home() {
|
|
|
133
133
|
key="featured-series"
|
|
134
134
|
allSeries={seriesItems}
|
|
135
135
|
maxItems={section.maxItems ?? 6}
|
|
136
|
-
scrollThreshold={section.scrollThreshold ?? 2}
|
|
137
136
|
/>
|
|
138
137
|
);
|
|
139
138
|
case 'featured-books':
|
|
@@ -45,10 +45,6 @@ export async function generateStaticParams() {
|
|
|
45
45
|
return (p.redirectFrom ?? []).includes(`/${basePath}/${p.slug}`);
|
|
46
46
|
});
|
|
47
47
|
|
|
48
|
-
if (filtered.length === 0) return [{ slug: '_' }];
|
|
49
|
-
// Work around Next dev static-param checks for percent-encoded Unicode paths
|
|
50
|
-
// under `output: "export"` by including encoded variants only in development.
|
|
51
|
-
// Production export keeps raw segment values.
|
|
52
48
|
const slugs = new Set<string>();
|
|
53
49
|
for (const post of filtered) {
|
|
54
50
|
slugs.add(post.slug);
|
|
@@ -56,6 +52,22 @@ export async function generateStaticParams() {
|
|
|
56
52
|
slugs.add(encodeURIComponent(post.slug));
|
|
57
53
|
}
|
|
58
54
|
}
|
|
55
|
+
|
|
56
|
+
// Also include redirectFrom slugs at this basePath (e.g. /posts/old-name → /posts/new-name).
|
|
57
|
+
for (const post of posts) {
|
|
58
|
+
for (const from of post.redirectFrom ?? []) {
|
|
59
|
+
const segments = from.split('/').filter(Boolean);
|
|
60
|
+
if (segments.length !== 2 || segments[0] !== basePath) continue;
|
|
61
|
+
if (from === getPostUrl(post)) continue;
|
|
62
|
+
const fromSlug = segments[1];
|
|
63
|
+
slugs.add(fromSlug);
|
|
64
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
65
|
+
slugs.add(encodeURIComponent(fromSlug));
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (slugs.size === 0) return [{ slug: '_' }];
|
|
59
71
|
return Array.from(slugs).map((slug) => ({ slug }));
|
|
60
72
|
}
|
|
61
73
|
|
|
@@ -63,7 +75,12 @@ export const dynamicParams = false;
|
|
|
63
75
|
|
|
64
76
|
export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise<Metadata> {
|
|
65
77
|
const { slug: rawSlug } = await params;
|
|
66
|
-
const
|
|
78
|
+
const slug = safeDecodeParam(rawSlug);
|
|
79
|
+
const basePath = getPostsBasePath();
|
|
80
|
+
const currentPath = `/${basePath}/${slug}`;
|
|
81
|
+
const post =
|
|
82
|
+
resolvePostFromParam(rawSlug) ??
|
|
83
|
+
getAllPosts().find(p => p.redirectFrom?.includes(currentPath));
|
|
67
84
|
|
|
68
85
|
if (!post) {
|
|
69
86
|
return {
|
|
@@ -73,7 +90,6 @@ export async function generateMetadata({ params }: { params: Promise<{ slug: str
|
|
|
73
90
|
|
|
74
91
|
const siteUrl = siteConfig.baseUrl.replace(/\/+$/, '');
|
|
75
92
|
const canonicalUrl = getPostUrl(post);
|
|
76
|
-
const currentPath = `/${getPostsBasePath()}/${safeDecodeParam(rawSlug)}`;
|
|
77
93
|
|
|
78
94
|
// For redirect pages, return minimal metadata pointing to the canonical URL
|
|
79
95
|
if (canonicalUrl !== currentPath) {
|
|
@@ -120,16 +136,19 @@ export default async function PostPage({
|
|
|
120
136
|
}) {
|
|
121
137
|
const { slug: rawSlug } = await params;
|
|
122
138
|
const slug = safeDecodeParam(rawSlug);
|
|
123
|
-
const
|
|
139
|
+
const basePath = getPostsBasePath();
|
|
140
|
+
const currentPath = `/${basePath}/${slug}`;
|
|
141
|
+
const post =
|
|
142
|
+
resolvePostFromParam(rawSlug) ??
|
|
143
|
+
getAllPosts().find(p => p.redirectFrom?.includes(currentPath));
|
|
124
144
|
|
|
125
145
|
if (!post) {
|
|
126
146
|
notFound();
|
|
127
147
|
}
|
|
128
148
|
|
|
129
149
|
// If the canonical URL differs from the current path, render a redirect page.
|
|
130
|
-
// This handles posts moved by autoPaths or customPaths
|
|
150
|
+
// This handles posts moved by autoPaths or customPaths, or renamed within the same prefix.
|
|
131
151
|
const canonicalUrl = getPostUrl(post);
|
|
132
|
-
const currentPath = `/${getPostsBasePath()}/${slug}`;
|
|
133
152
|
if (canonicalUrl !== currentPath) {
|
|
134
153
|
return <RedirectPage to={canonicalUrl} />;
|
|
135
154
|
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { generateAtomFeed } from '@/lib/feed-utils';
|
|
2
|
+
import { getPostsBasePath } from '@/lib/urls';
|
|
3
|
+
|
|
4
|
+
export const dynamic = 'force-static';
|
|
5
|
+
|
|
6
|
+
export async function GET() {
|
|
7
|
+
const basePath = getPostsBasePath();
|
|
8
|
+
return generateAtomFeed('posts', `/${basePath}/feed.atom`);
|
|
9
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { generateRssFeed } from '@/lib/feed-utils';
|
|
2
|
+
import { getPostsBasePath } from '@/lib/urls';
|
|
3
|
+
|
|
4
|
+
export const dynamic = 'force-static';
|
|
5
|
+
|
|
6
|
+
export async function GET() {
|
|
7
|
+
const basePath = getPostsBasePath();
|
|
8
|
+
return generateRssFeed('posts', `/${basePath}/feed.xml`);
|
|
9
|
+
}
|
|
@@ -8,14 +8,38 @@ import CoverImage from '@/components/CoverImage';
|
|
|
8
8
|
import Link from 'next/link';
|
|
9
9
|
import { t, resolveLocale } from '@/lib/i18n';
|
|
10
10
|
import { getPostUrl, getPostUrlInCollection } from '@/lib/urls';
|
|
11
|
+
import RedirectPage from '@/components/RedirectPage';
|
|
11
12
|
|
|
12
13
|
const PAGE_SIZE = siteConfig.pagination.series;
|
|
13
14
|
|
|
15
|
+
/** Returns the series whose index.mdx lists `path` in its redirectFrom array, or null. */
|
|
16
|
+
function findSeriesByRedirectFrom(path: string) {
|
|
17
|
+
for (const seriesSlug of Object.keys(getAllSeries())) {
|
|
18
|
+
const data = getSeriesData(seriesSlug);
|
|
19
|
+
if (data?.redirectFrom?.includes(path)) {
|
|
20
|
+
return { slug: seriesSlug, data };
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
|
|
14
26
|
export async function generateStaticParams() {
|
|
15
27
|
const allSeries = getAllSeries();
|
|
16
|
-
const slugs = Object.keys(allSeries);
|
|
17
|
-
|
|
18
|
-
|
|
28
|
+
const slugs = new Set(Object.keys(allSeries));
|
|
29
|
+
|
|
30
|
+
// Also include old slugs from redirectFrom entries at /series/[old-slug].
|
|
31
|
+
for (const seriesSlug of Object.keys(allSeries)) {
|
|
32
|
+
const data = getSeriesData(seriesSlug);
|
|
33
|
+
for (const from of data?.redirectFrom ?? []) {
|
|
34
|
+
const segments = from.split('/').filter(Boolean);
|
|
35
|
+
if (segments.length !== 2 || segments[0] !== 'series') continue;
|
|
36
|
+
if (from === `/series/${seriesSlug}`) continue;
|
|
37
|
+
slugs.add(segments[1]);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (slugs.size === 0) return [{ slug: '_' }];
|
|
42
|
+
return Array.from(slugs).map((slug) => ({ slug }));
|
|
19
43
|
}
|
|
20
44
|
|
|
21
45
|
export const dynamicParams = false;
|
|
@@ -23,8 +47,19 @@ export const dynamicParams = false;
|
|
|
23
47
|
export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise<Metadata> {
|
|
24
48
|
const { slug: rawSlug } = await params;
|
|
25
49
|
const slug = decodeURIComponent(rawSlug);
|
|
50
|
+
const currentPath = `/series/${slug}`;
|
|
51
|
+
|
|
52
|
+
const redirect = findSeriesByRedirectFrom(currentPath);
|
|
53
|
+
if (redirect) {
|
|
54
|
+
const siteUrl = siteConfig.baseUrl.replace(/\/+$/, '');
|
|
55
|
+
return {
|
|
56
|
+
title: redirect.data.title,
|
|
57
|
+
alternates: { canonical: `${siteUrl}/series/${redirect.slug}` },
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
26
61
|
const seriesData = getSeriesData(slug);
|
|
27
|
-
|
|
62
|
+
|
|
28
63
|
if (!seriesData) {
|
|
29
64
|
// If no explicit series metadata, try to infer from posts or return default
|
|
30
65
|
const posts = getSeriesPosts(slug);
|
|
@@ -64,6 +99,13 @@ export async function generateMetadata({ params }: { params: Promise<{ slug: str
|
|
|
64
99
|
export default async function SeriesPage({ params }: { params: Promise<{ slug: string }> }) {
|
|
65
100
|
const { slug: rawSlug } = await params;
|
|
66
101
|
const slug = decodeURIComponent(rawSlug);
|
|
102
|
+
const currentPath = `/series/${slug}`;
|
|
103
|
+
|
|
104
|
+
const redirect = findSeriesByRedirectFrom(currentPath);
|
|
105
|
+
if (redirect) {
|
|
106
|
+
return <RedirectPage to={`/series/${redirect.slug}`} />;
|
|
107
|
+
}
|
|
108
|
+
|
|
67
109
|
const seriesData = getSeriesData(slug);
|
|
68
110
|
const isCollection = seriesData?.type === 'collection';
|
|
69
111
|
const allPosts = isCollection ? getCollectionPosts(slug) : getSeriesPosts(slug);
|
|
@@ -6,7 +6,7 @@ import HorizontalScroll from './HorizontalScroll';
|
|
|
6
6
|
import CoverImage from './CoverImage';
|
|
7
7
|
import { useLanguage } from './LanguageProvider';
|
|
8
8
|
import { shuffle, shuffleSeeded } from '@/lib/shuffle';
|
|
9
|
-
import { getPostUrl } from '@/lib/urls';
|
|
9
|
+
import { getPostUrl, getSeriesListUrl } from '@/lib/urls';
|
|
10
10
|
|
|
11
11
|
export interface SeriesItem {
|
|
12
12
|
name: string;
|
|
@@ -21,10 +21,9 @@ export interface SeriesItem {
|
|
|
21
21
|
interface CuratedSeriesSectionProps {
|
|
22
22
|
allSeries: SeriesItem[];
|
|
23
23
|
maxItems: number;
|
|
24
|
-
scrollThreshold: number;
|
|
25
24
|
}
|
|
26
25
|
|
|
27
|
-
export default function CuratedSeriesSection({ allSeries, maxItems
|
|
26
|
+
export default function CuratedSeriesSection({ allSeries, maxItems }: CuratedSeriesSectionProps) {
|
|
28
27
|
const { t } = useLanguage();
|
|
29
28
|
// Use a daily seed so SSR and client hydration agree on the initial order,
|
|
30
29
|
// preventing a visible reshuffle flash on page load.
|
|
@@ -47,7 +46,7 @@ export default function CuratedSeriesSection({ allSeries, maxItems, scrollThresh
|
|
|
47
46
|
{allSeries.length > maxItems && (
|
|
48
47
|
<button
|
|
49
48
|
onClick={handleShuffle}
|
|
50
|
-
className="text-sm text-muted hover:text-accent
|
|
49
|
+
className="rounded-sm text-sm text-muted transition-colors hover:text-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 focus-visible:ring-offset-2"
|
|
51
50
|
aria-label={t('shuffle_series')}
|
|
52
51
|
title={t('shuffle_series')}
|
|
53
52
|
>
|
|
@@ -56,21 +55,18 @@ export default function CuratedSeriesSection({ allSeries, maxItems, scrollThresh
|
|
|
56
55
|
</svg>
|
|
57
56
|
</button>
|
|
58
57
|
)}
|
|
59
|
-
<Link href=
|
|
58
|
+
<Link href={getSeriesListUrl()} className="text-sm text-muted hover:text-accent transition-colors no-underline">
|
|
60
59
|
{t('all_series')} →
|
|
61
60
|
</Link>
|
|
62
61
|
</div>
|
|
63
62
|
</div>
|
|
64
|
-
<HorizontalScroll
|
|
65
|
-
|
|
66
|
-
scrollThreshold={scrollThreshold}
|
|
67
|
-
>
|
|
68
|
-
<div className={`flex gap-8 ${displayed.length > scrollThreshold ? 'pb-4' : ''}`}>
|
|
63
|
+
<HorizontalScroll>
|
|
64
|
+
<div className={`flex gap-8 ${displayed.length > 1 ? 'pb-4' : ''}`}>
|
|
69
65
|
{displayed.map((series, idx) => (
|
|
70
66
|
<div
|
|
71
67
|
key={series.name}
|
|
72
68
|
className={`card-base group flex flex-col p-0 overflow-hidden snap-start ${
|
|
73
|
-
displayed.length >
|
|
69
|
+
displayed.length > 1
|
|
74
70
|
? 'w-[85vw] md:w-[calc(50%-1rem)] flex-shrink-0'
|
|
75
71
|
: 'flex-1 md:max-w-[calc(50%-1rem)]'
|
|
76
72
|
}`}
|
|
@@ -80,7 +80,7 @@ export default function FeaturedStoriesSection({ allFeatured, maxItems }: Featur
|
|
|
80
80
|
{canShuffle && (
|
|
81
81
|
<button
|
|
82
82
|
onClick={handleShuffle}
|
|
83
|
-
className="text-sm text-muted hover:text-accent
|
|
83
|
+
className="rounded-sm text-sm text-muted transition-colors hover:text-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 focus-visible:ring-offset-2"
|
|
84
84
|
aria-label={t('shuffle_posts')}
|
|
85
85
|
title={t('shuffle_posts')}
|
|
86
86
|
>
|
|
@@ -79,7 +79,7 @@ export default function FlowCalendarSidebar({ entryDates, currentDate, tags, sel
|
|
|
79
79
|
}, [firstDay, daysInMonth]);
|
|
80
80
|
|
|
81
81
|
return (
|
|
82
|
-
<aside className="hidden lg:block sticky top-20 self-start w-[280px] max-h-[calc(100vh-6rem)]">
|
|
82
|
+
<aside className="hidden lg:block sticky top-20 self-start w-[280px] max-h-[calc(100vh-6rem)] select-none">
|
|
83
83
|
{breadcrumb && <div className="mb-4">{breadcrumb}</div>}
|
|
84
84
|
<div className="border border-muted/20 rounded-lg p-4">
|
|
85
85
|
{/* Month navigation */}
|
|
@@ -9,7 +9,7 @@ import Pagination from '@/components/Pagination';
|
|
|
9
9
|
interface FlowItem {
|
|
10
10
|
slug: string;
|
|
11
11
|
date: string;
|
|
12
|
-
title
|
|
12
|
+
title?: string;
|
|
13
13
|
excerpt: string;
|
|
14
14
|
tags: string[];
|
|
15
15
|
}
|
|
@@ -102,6 +102,7 @@ export default function FlowContent({ flows, allFlows, entryDates, tags, current
|
|
|
102
102
|
<FlowTimelineEntry
|
|
103
103
|
key={flow.slug}
|
|
104
104
|
date={flow.date}
|
|
105
|
+
title={flow.title}
|
|
105
106
|
excerpt={flow.excerpt}
|
|
106
107
|
tags={flow.tags}
|
|
107
108
|
slug={flow.slug}
|
|
@@ -3,12 +3,15 @@ import Tag from './Tag';
|
|
|
3
3
|
|
|
4
4
|
interface FlowTimelineEntryProps {
|
|
5
5
|
date: string;
|
|
6
|
+
title?: string;
|
|
6
7
|
excerpt: string;
|
|
7
8
|
tags: string[];
|
|
8
9
|
slug: string;
|
|
9
10
|
}
|
|
10
11
|
|
|
11
|
-
export default function FlowTimelineEntry({ date, excerpt, tags, slug }: FlowTimelineEntryProps) {
|
|
12
|
+
export default function FlowTimelineEntry({ date, title, excerpt, tags, slug }: FlowTimelineEntryProps) {
|
|
13
|
+
const hasExplicitTitle = title && title !== date;
|
|
14
|
+
|
|
12
15
|
return (
|
|
13
16
|
<article className="relative pl-6 pb-8 border-l-2 border-muted/20 last:pb-0">
|
|
14
17
|
{/* Timeline dot */}
|
|
@@ -16,6 +19,9 @@ export default function FlowTimelineEntry({ date, excerpt, tags, slug }: FlowTim
|
|
|
16
19
|
|
|
17
20
|
<Link href={`/flows/${slug}`} className="no-underline group">
|
|
18
21
|
<time className="text-sm font-mono text-accent group-hover:text-accent/70 transition-colors">{date}</time>
|
|
22
|
+
{hasExplicitTitle && (
|
|
23
|
+
<h3 className="mt-1 text-base font-semibold text-heading group-hover:text-accent transition-colors">{title}</h3>
|
|
24
|
+
)}
|
|
19
25
|
</Link>
|
|
20
26
|
{excerpt && (
|
|
21
27
|
<p className="mt-1.5 text-sm text-muted leading-relaxed line-clamp-3">{excerpt}</p>
|
|
@@ -11,12 +11,12 @@ export default function Footer() {
|
|
|
11
11
|
const { t, language } = useLanguage();
|
|
12
12
|
|
|
13
13
|
return (
|
|
14
|
-
<footer className="bg-muted/5 border-t border-muted/10 mt-auto">
|
|
14
|
+
<footer className="bg-muted/5 border-t border-muted/10 mt-auto select-none">
|
|
15
15
|
<div className="max-w-6xl mx-auto px-6 py-10 lg:py-16">
|
|
16
16
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-8 lg:gap-12 mb-10 lg:mb-12">
|
|
17
17
|
{/* Brand */}
|
|
18
18
|
<div className="col-span-2 lg:col-span-2">
|
|
19
|
-
<Link href="/" className="flex items-center gap-2 mb-4 group no-underline">
|
|
19
|
+
<Link href="/" className="flex items-center justify-center lg:justify-start gap-2 mb-4 group no-underline">
|
|
20
20
|
<svg
|
|
21
21
|
viewBox="0 0 32 32"
|
|
22
22
|
className="w-6 h-6 text-accent group-hover:rotate-12 transition-transform duration-300"
|
|
@@ -34,13 +34,13 @@ export default function Footer() {
|
|
|
34
34
|
</svg>
|
|
35
35
|
<span className="font-serif font-bold text-lg text-heading">{resolveLocaleValue(siteConfig.title, language)}</span>
|
|
36
36
|
</Link>
|
|
37
|
-
<p className="text-sm text-muted leading-relaxed max-w-sm">
|
|
37
|
+
<p className="text-sm text-muted leading-relaxed max-w-sm mx-auto text-center lg:mx-0 lg:text-left">
|
|
38
38
|
{resolveLocaleValue(siteConfig.description, language)}
|
|
39
39
|
</p>
|
|
40
40
|
</div>
|
|
41
41
|
|
|
42
42
|
{/* Navigation */}
|
|
43
|
-
<div>
|
|
43
|
+
<div className="text-center lg:text-left">
|
|
44
44
|
<h4 className="font-sans font-bold text-xs uppercase tracking-widest text-muted/80 mb-6">{t('explore')}</h4>
|
|
45
45
|
<ul className="space-y-3 text-sm">
|
|
46
46
|
{[...(siteConfig.footer?.explore ?? [])].sort((a, b) => a.weight - b.weight).map((item) => {
|
|
@@ -59,7 +59,7 @@ export default function Footer() {
|
|
|
59
59
|
</div>
|
|
60
60
|
|
|
61
61
|
{/* Connect */}
|
|
62
|
-
<div>
|
|
62
|
+
<div className="text-center lg:text-left">
|
|
63
63
|
<h4 className="font-sans font-bold text-xs uppercase tracking-widest text-muted/80 mb-6">{t('connect')}</h4>
|
|
64
64
|
<ul className="space-y-3 text-sm">
|
|
65
65
|
{[...(siteConfig.footer?.connect ?? [])].sort((a, b) => a.weight - b.weight).map((item) => {
|
|
@@ -67,7 +67,7 @@ export default function Footer() {
|
|
|
67
67
|
const key = item.name.toLowerCase() as TranslationKey;
|
|
68
68
|
const translated = t(key);
|
|
69
69
|
const label = translated !== key ? translated : item.name;
|
|
70
|
-
const className = "text-foreground/80 hover:text-accent transition-colors no-underline flex items-center gap-2";
|
|
70
|
+
const className = "text-foreground/80 hover:text-accent transition-colors no-underline flex items-center justify-center lg:justify-start gap-2";
|
|
71
71
|
return (
|
|
72
72
|
<li key={item.url}>
|
|
73
73
|
{isExternal ? (
|
|
@@ -4,15 +4,11 @@ import { useRef, useState, useEffect, ReactNode, useCallback } from 'react';
|
|
|
4
4
|
|
|
5
5
|
interface HorizontalScrollProps {
|
|
6
6
|
children: ReactNode;
|
|
7
|
-
itemCount: number;
|
|
8
|
-
scrollThreshold: number;
|
|
9
7
|
className?: string;
|
|
10
8
|
}
|
|
11
9
|
|
|
12
10
|
export default function HorizontalScroll({
|
|
13
11
|
children,
|
|
14
|
-
itemCount,
|
|
15
|
-
scrollThreshold,
|
|
16
12
|
className = ''
|
|
17
13
|
}: HorizontalScrollProps) {
|
|
18
14
|
const scrollRef = useRef<HTMLDivElement>(null);
|
|
@@ -20,7 +16,7 @@ export default function HorizontalScroll({
|
|
|
20
16
|
const [canScrollLeft, setCanScrollLeft] = useState(false);
|
|
21
17
|
const [canScrollRight, setCanScrollRight] = useState(false);
|
|
22
18
|
|
|
23
|
-
const
|
|
19
|
+
const hasOverflow = canScrollLeft || canScrollRight;
|
|
24
20
|
|
|
25
21
|
const updateScrollState = useCallback(() => {
|
|
26
22
|
if (!scrollRef.current) return;
|
|
@@ -54,7 +50,6 @@ export default function HorizontalScroll({
|
|
|
54
50
|
});
|
|
55
51
|
}, []);
|
|
56
52
|
|
|
57
|
-
// Handle keyboard navigation
|
|
58
53
|
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
|
59
54
|
if (e.key === 'ArrowLeft' && canScrollLeft) {
|
|
60
55
|
e.preventDefault();
|
|
@@ -65,10 +60,6 @@ export default function HorizontalScroll({
|
|
|
65
60
|
}
|
|
66
61
|
}, [canScrollLeft, canScrollRight, scroll]);
|
|
67
62
|
|
|
68
|
-
if (!shouldShowArrows) {
|
|
69
|
-
return <div className={className}>{children}</div>;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
63
|
return (
|
|
73
64
|
<div
|
|
74
65
|
ref={containerRef}
|
|
@@ -78,10 +69,10 @@ export default function HorizontalScroll({
|
|
|
78
69
|
role="region"
|
|
79
70
|
aria-label="Scrollable content"
|
|
80
71
|
>
|
|
81
|
-
{/* Left Arrow
|
|
72
|
+
{/* Left Arrow */}
|
|
82
73
|
<button
|
|
83
74
|
onClick={() => scroll('left')}
|
|
84
|
-
className={`absolute -left-4 lg:-left-14 top-1/2 -translate-y-1/2 z-20 p-2 lg:p-3 bg-background border border-muted/20 rounded-full shadow-lg transition-all duration-200 hidden md:flex items-center justify-center ${
|
|
75
|
+
className={`absolute -left-4 lg:-left-14 top-1/2 -translate-y-1/2 z-20 p-2 lg:p-3 bg-background border border-muted/20 rounded-full shadow-lg transition-all duration-200 ${hasOverflow ? 'hidden md:flex' : 'hidden'} items-center justify-center ${
|
|
85
76
|
canScrollLeft
|
|
86
77
|
? 'text-muted hover:text-accent hover:border-accent/40 hover:shadow-accent/10 focus:outline-none focus:ring-2 focus:ring-accent/50'
|
|
87
78
|
: 'text-muted/30 cursor-not-allowed opacity-50'
|
|
@@ -104,10 +95,10 @@ export default function HorizontalScroll({
|
|
|
104
95
|
{children}
|
|
105
96
|
</div>
|
|
106
97
|
|
|
107
|
-
{/* Right Arrow
|
|
98
|
+
{/* Right Arrow */}
|
|
108
99
|
<button
|
|
109
100
|
onClick={() => scroll('right')}
|
|
110
|
-
className={`absolute -right-4 lg:-right-14 top-1/2 -translate-y-1/2 z-20 p-2 lg:p-3 bg-background border border-muted/20 rounded-full shadow-lg transition-all duration-200 hidden md:flex items-center justify-center ${
|
|
101
|
+
className={`absolute -right-4 lg:-right-14 top-1/2 -translate-y-1/2 z-20 p-2 lg:p-3 bg-background border border-muted/20 rounded-full shadow-lg transition-all duration-200 ${hasOverflow ? 'hidden md:flex' : 'hidden'} items-center justify-center ${
|
|
111
102
|
canScrollRight
|
|
112
103
|
? 'text-muted hover:text-accent hover:border-accent/40 hover:shadow-accent/10 focus:outline-none focus:ring-2 focus:ring-accent/50'
|
|
113
104
|
: 'text-muted/30 cursor-not-allowed opacity-50'
|
|
@@ -41,4 +41,10 @@ describe("MarkdownRenderer", () => {
|
|
|
41
41
|
expect(html).toContain("not-prose w-full min-w-0 max-w-full");
|
|
42
42
|
expect(html).toContain("overflow-x-auto");
|
|
43
43
|
});
|
|
44
|
+
|
|
45
|
+
test("wraps content in a background container for copy-paste fidelity", () => {
|
|
46
|
+
const content = "Hello world";
|
|
47
|
+
const html = renderToStaticMarkup(<MarkdownRenderer content={content} />);
|
|
48
|
+
expect(html).toMatch(/class="[^"]*\bbg-background\b[^"]*"/);
|
|
49
|
+
});
|
|
44
50
|
});
|