@hutusi/amytis 1.13.0 → 1.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +1 -1
- package/.github/workflows/publish.yml +2 -2
- package/CHANGELOG.md +32 -0
- package/GEMINI.md +9 -1
- package/README.md +36 -2
- package/README.zh.md +36 -2
- package/TODO.md +10 -0
- package/bun.lock +123 -91
- package/content/flows/2026/03/05.md +1 -0
- package/content/flows/2026/03/07.md +2 -0
- package/content/series/modern-web-dev/index.mdx +4 -2
- package/content/series/rst-legacy/deeper-notes/images/test.svg +4 -0
- package/content/series/rst-legacy/deeper-notes/index.rst +15 -0
- package/content/series/rst-legacy/getting-started.rst +24 -0
- package/content/series/rst-legacy/index.rst +9 -0
- package/content/series/rst-readme/README.rst +9 -0
- package/content/series/rst-readme/readme-index-post.rst +10 -0
- package/content/series/rst-toctree/first-post.rst +6 -0
- package/content/series/rst-toctree/index.rst +10 -0
- package/content/series/rst-toctree/second-post.rst +6 -0
- package/content/series/rst-toctree-precedence/first-post.rst +6 -0
- package/content/series/rst-toctree-precedence/index.rst +12 -0
- package/content/series/rst-toctree-precedence/second-post.rst +6 -0
- package/docs/ARCHITECTURE.md +30 -4
- package/docs/CONTRIBUTING.md +11 -0
- package/docs/DIGITAL_GARDEN.md +22 -1
- package/eslint.config.mjs +2 -0
- package/next.config.ts +2 -2
- package/package.json +27 -21
- package/packages/create-amytis/package.json +1 -1
- package/packages/create-amytis/src/index.test.ts +43 -1
- package/packages/create-amytis/src/index.ts +64 -8
- package/public/next-image-export-optimizer-hashes.json +14 -73
- package/scripts/build-pagefind.ts +172 -0
- package/scripts/copy-assets.ts +246 -56
- package/scripts/generate-knowledge-graph.ts +2 -1
- package/scripts/new-flow.ts +1 -0
- package/scripts/render-rst.py +719 -0
- package/scripts/run-with-rst-python.ts +42 -0
- package/src/app/[slug]/[postSlug]/page.tsx +20 -10
- package/src/app/[slug]/page/[page]/page.tsx +15 -0
- 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/globals.css +165 -0
- package/src/app/page.tsx +1 -0
- 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/[page]/page.tsx +74 -6
- package/src/app/series/[slug]/page.tsx +11 -13
- package/src/app/series/page.tsx +3 -3
- package/src/components/AuthorCard.tsx +25 -16
- package/src/components/CoverImage.tsx +5 -2
- 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 +1 -1
- package/src/components/MarkdownRenderer.test.tsx +22 -0
- package/src/components/MarkdownRenderer.tsx +22 -17
- package/src/components/Navbar.tsx +1 -1
- package/src/components/PostSidebar.tsx +1 -1
- package/src/components/RecentNotesSection.tsx +4 -0
- package/src/components/RstRenderer.test.tsx +93 -0
- package/src/components/RstRenderer.tsx +122 -0
- package/src/layouts/PostLayout.tsx +5 -1
- package/src/layouts/SimpleLayout.tsx +10 -3
- package/src/lib/feed-utils.ts +158 -18
- package/src/lib/image-utils.test.ts +19 -0
- package/src/lib/image-utils.ts +11 -0
- package/src/lib/markdown.test.ts +140 -2
- package/src/lib/markdown.ts +747 -214
- package/src/lib/rehype-image-metadata.ts +2 -2
- package/src/lib/rst-renderer.test.ts +355 -0
- package/src/lib/rst-renderer.ts +617 -0
- package/src/lib/rst.test.ts +140 -0
- package/src/lib/rst.ts +470 -0
- package/src/lib/series-redirects.ts +42 -0
- package/tests/e2e/navigation.test.ts +26 -0
- package/tests/integration/collections.test.ts +17 -2
- package/tests/integration/feed-utils.test.ts +65 -0
- package/tests/integration/flow-title.test.ts +53 -0
- package/tests/integration/reading-time-headings.test.ts +5 -9
- package/tests/integration/series-draft.test.ts +16 -2
- package/tests/integration/series.test.ts +93 -0
- package/tests/tooling/build-pagefind.test.ts +66 -0
- package/tests/unit/static-params.test.ts +140 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
|
|
5
|
+
const [command, ...args] = Bun.argv.slice(2);
|
|
6
|
+
|
|
7
|
+
if (!command) {
|
|
8
|
+
console.error('Missing command to run.');
|
|
9
|
+
process.exit(1);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const env = { ...process.env };
|
|
13
|
+
if (!env.AMYTIS_RST_PYTHON) {
|
|
14
|
+
const localPython = path.join(
|
|
15
|
+
process.cwd(),
|
|
16
|
+
'.venv-rst',
|
|
17
|
+
process.platform === 'win32' ? 'Scripts' : 'bin',
|
|
18
|
+
process.platform === 'win32' ? 'python.exe' : 'python',
|
|
19
|
+
);
|
|
20
|
+
if (fs.existsSync(localPython)) {
|
|
21
|
+
env.AMYTIS_RST_PYTHON = localPython;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const child = spawn(command, args, {
|
|
26
|
+
stdio: 'inherit',
|
|
27
|
+
env,
|
|
28
|
+
shell: process.platform === 'win32',
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
child.on('exit', (code, signal) => {
|
|
32
|
+
if (signal) {
|
|
33
|
+
process.kill(process.pid, signal);
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
process.exit(code ?? 1);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
child.on('error', (error) => {
|
|
40
|
+
console.error(error.message);
|
|
41
|
+
process.exit(1);
|
|
42
|
+
});
|
|
@@ -67,17 +67,26 @@ export async function generateStaticParams() {
|
|
|
67
67
|
}
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
-
// Work around Next dev static-param checks for percent-encoded Unicode
|
|
71
|
-
// under `output: "export"` — dev server may receive
|
|
70
|
+
// Work around Next dev static-param checks for percent-encoded Unicode slugs
|
|
71
|
+
// under `output: "export"` — dev server may receive encoded forms of either segment.
|
|
72
72
|
// Include encoded variants in development only; production export keeps raw segment values.
|
|
73
73
|
if (process.env.NODE_ENV !== 'production') {
|
|
74
74
|
const existing = new Set(params.map(p => `${p.slug}/${p.postSlug}`));
|
|
75
75
|
for (const p of [...params]) {
|
|
76
|
+
const encodedSlug = encodeURIComponent(p.slug);
|
|
76
77
|
const encodedPostSlug = encodeURIComponent(p.postSlug);
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
78
|
+
const variants = [
|
|
79
|
+
{ slug: p.slug, postSlug: encodedPostSlug },
|
|
80
|
+
{ slug: encodedSlug, postSlug: p.postSlug },
|
|
81
|
+
{ slug: encodedSlug, postSlug: encodedPostSlug },
|
|
82
|
+
];
|
|
83
|
+
|
|
84
|
+
for (const variant of variants) {
|
|
85
|
+
const key = `${variant.slug}/${variant.postSlug}`;
|
|
86
|
+
if (!existing.has(key)) {
|
|
87
|
+
existing.add(key);
|
|
88
|
+
params.push(variant);
|
|
89
|
+
}
|
|
81
90
|
}
|
|
82
91
|
}
|
|
83
92
|
}
|
|
@@ -154,7 +163,8 @@ export default async function PrefixPostPage({
|
|
|
154
163
|
params: Promise<{ slug: string; postSlug: string }>;
|
|
155
164
|
}) {
|
|
156
165
|
const { slug: prefix, postSlug: rawPostSlug } = await params;
|
|
157
|
-
const
|
|
166
|
+
const decodedPrefix = safeDecodeParam(prefix);
|
|
167
|
+
const currentPath = `/${decodedPrefix}/${safeDecodeParam(rawPostSlug)}`;
|
|
158
168
|
|
|
159
169
|
// Resolve the post: first by slug, then fall back to redirectFrom lookup for renamed slugs.
|
|
160
170
|
const post =
|
|
@@ -168,9 +178,9 @@ export default async function PrefixPostPage({
|
|
|
168
178
|
// or a legacy redirectFrom path declared on the resolved post.
|
|
169
179
|
const basePath = getPostsBasePath();
|
|
170
180
|
const customPaths = getSeriesCustomPaths();
|
|
171
|
-
const isValidBasePath =
|
|
172
|
-
const matchedSeriesSlug = Object.entries(customPaths).find(([, path]) => path ===
|
|
173
|
-
const isAutoSeriesPath = getSeriesAutoPaths() && !Object.hasOwn(customPaths,
|
|
181
|
+
const isValidBasePath = decodedPrefix === basePath && basePath !== 'posts';
|
|
182
|
+
const matchedSeriesSlug = Object.entries(customPaths).find(([, path]) => path === decodedPrefix)?.[0];
|
|
183
|
+
const isAutoSeriesPath = getSeriesAutoPaths() && !Object.hasOwn(customPaths, decodedPrefix) && getSeriesData(decodedPrefix) !== null;
|
|
174
184
|
const isLegacyRedirect = post.redirectFrom?.includes(currentPath) ?? false;
|
|
175
185
|
|
|
176
186
|
if (!isValidBasePath && !matchedSeriesSlug && !isAutoSeriesPath && !isLegacyRedirect) {
|
|
@@ -57,6 +57,21 @@ export async function generateStaticParams() {
|
|
|
57
57
|
}
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
// Work around Next dev static-param checks for percent-encoded Unicode slugs
|
|
61
|
+
// under `output: "export"` — dev server may receive encoded forms of the
|
|
62
|
+
// prefix segment for paginated listings.
|
|
63
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
64
|
+
const existing = new Set(params.map(p => `${p.slug}/${p.page}`));
|
|
65
|
+
for (const p of [...params]) {
|
|
66
|
+
const encodedSlug = encodeURIComponent(p.slug);
|
|
67
|
+
const key = `${encodedSlug}/${p.page}`;
|
|
68
|
+
if (!existing.has(key)) {
|
|
69
|
+
existing.add(key);
|
|
70
|
+
params.push({ slug: encodedSlug, page: p.page });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
60
75
|
// Placeholder keeps Next.js happy with output: export when no custom paths configured.
|
|
61
76
|
// dynamicParams = false ensures any unrecognised slug/page combo returns 404.
|
|
62
77
|
return params.length > 0 ? params : [{ slug: '_', page: '2' }];
|
package/src/app/archive/page.tsx
CHANGED
|
@@ -128,12 +128,12 @@ export default function ArchivePage() {
|
|
|
128
128
|
<li key={post.slug} className="group">
|
|
129
129
|
<Link href={getPostUrl(post)} className="block no-underline">
|
|
130
130
|
<div className="flex flex-col sm:flex-row sm:items-baseline justify-between gap-2 sm:gap-6">
|
|
131
|
-
<div className="flex items-baseline gap-6">
|
|
131
|
+
<div className="flex items-baseline gap-6 min-w-0 flex-1">
|
|
132
132
|
<span className="font-mono text-base text-muted shrink-0 w-8">
|
|
133
133
|
{day}
|
|
134
134
|
</span>
|
|
135
|
-
<div className="flex items-baseline gap-2">
|
|
136
|
-
<h4 className="text-xl font-serif font-medium text-heading/80 group-hover:text-accent transition-colors duration-200">
|
|
135
|
+
<div className="flex items-baseline gap-2 min-w-0 flex-1">
|
|
136
|
+
<h4 className="text-xl font-serif font-medium text-heading/80 group-hover:text-accent transition-colors duration-200 truncate">
|
|
137
137
|
{post.title}
|
|
138
138
|
</h4>
|
|
139
139
|
{post.series && (
|
|
@@ -147,7 +147,10 @@ export default function ArchivePage() {
|
|
|
147
147
|
</div>
|
|
148
148
|
</div>
|
|
149
149
|
{showAuthors && post.authors.length > 0 && (
|
|
150
|
-
<span
|
|
150
|
+
<span
|
|
151
|
+
title={post.authors.join(', ')}
|
|
152
|
+
className="text-sm font-sans italic text-muted/60 hidden sm:block max-w-[12rem] md:max-w-[16rem] lg:max-w-[20rem] truncate text-right shrink-0"
|
|
153
|
+
>
|
|
151
154
|
{post.authors.join(', ')}
|
|
152
155
|
</span>
|
|
153
156
|
)}
|
|
@@ -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/globals.css
CHANGED
|
@@ -451,3 +451,168 @@ body {
|
|
|
451
451
|
[id] {
|
|
452
452
|
scroll-margin-top: 5rem;
|
|
453
453
|
}
|
|
454
|
+
|
|
455
|
+
.rst-rendered figure {
|
|
456
|
+
margin: 2rem 0;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
.rst-rendered figure > img {
|
|
460
|
+
display: block;
|
|
461
|
+
width: 100%;
|
|
462
|
+
height: auto;
|
|
463
|
+
border-radius: 0.75rem;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
.rst-rendered figcaption {
|
|
467
|
+
margin-top: 0.75rem;
|
|
468
|
+
text-align: center;
|
|
469
|
+
font-size: 0.95rem;
|
|
470
|
+
line-height: 1.6;
|
|
471
|
+
color: var(--muted);
|
|
472
|
+
font-style: italic;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
.rst-rendered aside.admonition {
|
|
476
|
+
margin: 2rem 0;
|
|
477
|
+
border-left: 4px solid var(--accent);
|
|
478
|
+
border-radius: 0.75rem;
|
|
479
|
+
padding: 1rem 1.25rem;
|
|
480
|
+
background: color-mix(in srgb, var(--accent) 8%, var(--background));
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
.rst-rendered aside.admonition > :first-child {
|
|
484
|
+
margin-top: 0;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
.rst-rendered aside.admonition > :last-child {
|
|
488
|
+
margin-bottom: 0;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
.rst-rendered .admonition-title {
|
|
492
|
+
margin-bottom: 0.5rem;
|
|
493
|
+
font-family: var(--font-inter), ui-sans-serif, system-ui, sans-serif;
|
|
494
|
+
font-size: 0.8rem;
|
|
495
|
+
font-weight: 700;
|
|
496
|
+
letter-spacing: 0.08em;
|
|
497
|
+
text-transform: uppercase;
|
|
498
|
+
color: var(--heading);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
.rst-rendered .docutils.literal {
|
|
502
|
+
border: 1px solid color-mix(in srgb, var(--muted) 24%, transparent);
|
|
503
|
+
border-radius: 0.375rem;
|
|
504
|
+
background: color-mix(in srgb, var(--muted) 10%, var(--background));
|
|
505
|
+
padding: 0.1rem 0.35rem;
|
|
506
|
+
font-size: 0.9em;
|
|
507
|
+
color: var(--heading);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
.rst-rendered .dtag {
|
|
511
|
+
display: inline-block;
|
|
512
|
+
border: 1px solid color-mix(in srgb, var(--accent) 28%, transparent);
|
|
513
|
+
border-radius: 999px;
|
|
514
|
+
background: color-mix(in srgb, var(--accent) 10%, var(--background));
|
|
515
|
+
padding: 0.1rem 0.55rem;
|
|
516
|
+
font-size: 0.78rem;
|
|
517
|
+
font-weight: 600;
|
|
518
|
+
color: var(--accent);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
.rst-rendered a[href]:not([role="doc-noteref"]) {
|
|
522
|
+
color: var(--accent);
|
|
523
|
+
text-decoration: none;
|
|
524
|
+
transition: color 160ms ease, text-decoration-color 160ms ease;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
.rst-rendered a[href]:not([role="doc-noteref"]):hover,
|
|
528
|
+
.rst-rendered a[href]:not([role="doc-noteref"]):focus-visible {
|
|
529
|
+
color: var(--accent);
|
|
530
|
+
text-decoration: underline;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
.rst-rendered .numref {
|
|
534
|
+
color: inherit;
|
|
535
|
+
font-weight: inherit;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
.rst-rendered .rst-table-wrapper {
|
|
539
|
+
overflow-x: auto;
|
|
540
|
+
margin: 2rem 0;
|
|
541
|
+
border: 1px solid color-mix(in srgb, var(--muted) 20%, transparent);
|
|
542
|
+
border-radius: 0.5rem;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
.rst-rendered .rst-table-wrapper > table {
|
|
546
|
+
min-width: 100%;
|
|
547
|
+
margin: 0;
|
|
548
|
+
font-size: 0.875rem;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
.rst-rendered math {
|
|
552
|
+
display: inline-block;
|
|
553
|
+
max-width: 100%;
|
|
554
|
+
overflow-x: auto;
|
|
555
|
+
vertical-align: middle;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
.rst-rendered pre.literal-block,
|
|
559
|
+
.rst-rendered pre.code.literal-block {
|
|
560
|
+
overflow-x: auto;
|
|
561
|
+
border: 1px solid color-mix(in srgb, var(--muted) 28%, transparent);
|
|
562
|
+
border-radius: 0.875rem;
|
|
563
|
+
background: color-mix(in srgb, var(--heading) 7%, var(--background));
|
|
564
|
+
padding: 1rem 1.25rem;
|
|
565
|
+
color: var(--heading);
|
|
566
|
+
font-size: 0.95rem;
|
|
567
|
+
line-height: 1.7;
|
|
568
|
+
box-shadow: inset 0 1px 0 color-mix(in srgb, white 60%, transparent);
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
.dark .rst-rendered pre.literal-block,
|
|
572
|
+
.dark .rst-rendered pre.code.literal-block {
|
|
573
|
+
border-color: color-mix(in srgb, var(--muted) 22%, transparent);
|
|
574
|
+
background: color-mix(in srgb, var(--foreground) 9%, var(--background));
|
|
575
|
+
color: var(--foreground);
|
|
576
|
+
box-shadow: inset 0 1px 0 color-mix(in srgb, white 5%, transparent);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
.rst-rendered pre.code.literal-block code {
|
|
580
|
+
border: 0;
|
|
581
|
+
background: transparent;
|
|
582
|
+
padding: 0;
|
|
583
|
+
color: inherit;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
.rst-rendered pre.code.literal-block .keyword,
|
|
587
|
+
.rst-rendered pre.code.literal-block .name.function {
|
|
588
|
+
color: var(--accent);
|
|
589
|
+
font-weight: 600;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
.rst-rendered pre.code.literal-block .literal.string,
|
|
593
|
+
.rst-rendered pre.code.literal-block .literal.string.double {
|
|
594
|
+
color: color-mix(in srgb, var(--accent) 68%, var(--foreground));
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
.rst-rendered a[role="doc-noteref"] {
|
|
598
|
+
font-size: 0.82em;
|
|
599
|
+
vertical-align: super;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
.rst-rendered .footnote-list {
|
|
603
|
+
margin: 1.5rem 0 2rem;
|
|
604
|
+
border-top: 1px solid color-mix(in srgb, var(--muted) 24%, transparent);
|
|
605
|
+
padding-top: 1rem;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
.rst-rendered .footnote {
|
|
609
|
+
margin: 0.75rem 0;
|
|
610
|
+
color: var(--muted);
|
|
611
|
+
font-size: 0.95rem;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
.rst-rendered .footnote .label {
|
|
615
|
+
margin-right: 0.4rem;
|
|
616
|
+
font-weight: 600;
|
|
617
|
+
color: var(--heading);
|
|
618
|
+
}
|
package/src/app/page.tsx
CHANGED
|
@@ -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
|
+
}
|
|
@@ -7,22 +7,74 @@ import { siteConfig } from '../../../../../../site.config';
|
|
|
7
7
|
import CoverImage from '@/components/CoverImage';
|
|
8
8
|
import Link from 'next/link';
|
|
9
9
|
import { t, resolveLocale, tWith } from '@/lib/i18n';
|
|
10
|
+
import { getSeriesListUrl } from '@/lib/urls';
|
|
11
|
+
import RedirectPage from '@/components/RedirectPage';
|
|
12
|
+
import { findSeriesByRedirectFrom, safeDecodeParam } from '@/lib/series-redirects';
|
|
10
13
|
|
|
11
14
|
const PAGE_SIZE = siteConfig.pagination.series;
|
|
12
15
|
|
|
13
16
|
export async function generateStaticParams() {
|
|
14
17
|
const allSeries = getAllSeries();
|
|
18
|
+
const seriesBasePath = getSeriesListUrl();
|
|
19
|
+
const seen = new Set<string>();
|
|
20
|
+
const reservedSlugs = new Set(Object.keys(allSeries));
|
|
21
|
+
const claimedAliases = new Map<string, string>();
|
|
15
22
|
const params: { slug: string; page: string }[] = [];
|
|
16
|
-
|
|
23
|
+
const pushParam = (slug: string, page: string) => {
|
|
24
|
+
const key = `${slug}:${page}`;
|
|
25
|
+
if (seen.has(key)) return;
|
|
26
|
+
seen.add(key);
|
|
27
|
+
params.push({ slug, page });
|
|
28
|
+
};
|
|
29
|
+
|
|
17
30
|
Object.keys(allSeries).forEach(slug => {
|
|
18
31
|
const posts = allSeries[slug];
|
|
19
32
|
const totalPages = Math.ceil(posts.length / PAGE_SIZE);
|
|
20
33
|
if (totalPages > 1) {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
34
|
+
for (let i = 2; i <= totalPages; i++) {
|
|
35
|
+
pushParam(slug, i.toString());
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const data = getSeriesData(slug);
|
|
40
|
+
for (const from of data?.redirectFrom ?? []) {
|
|
41
|
+
const segments = from.split('/').filter(Boolean);
|
|
42
|
+
const expectedBase = seriesBasePath.replace(/^\/+|\/+$/g, '');
|
|
43
|
+
if (segments.length !== 2 || segments[0] !== expectedBase) continue;
|
|
44
|
+
const aliasSlug = segments[1];
|
|
45
|
+
if (aliasSlug === slug || totalPages <= 1) continue;
|
|
46
|
+
const claimedBy = claimedAliases.get(aliasSlug);
|
|
47
|
+
if (claimedBy && claimedBy !== slug) {
|
|
48
|
+
throw new Error(
|
|
49
|
+
`[amytis] series redirectFrom alias "${from}" is claimed by both "${claimedBy}" and "${slug}".`
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
if (!claimedBy && reservedSlugs.has(aliasSlug)) {
|
|
53
|
+
throw new Error(
|
|
54
|
+
`[amytis] series redirectFrom alias "${from}" for "${slug}" conflicts with an existing series slug.`
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
claimedAliases.set(aliasSlug, slug);
|
|
58
|
+
reservedSlugs.add(aliasSlug);
|
|
59
|
+
for (let i = 2; i <= totalPages; i++) {
|
|
60
|
+
pushParam(aliasSlug, i.toString());
|
|
61
|
+
}
|
|
24
62
|
}
|
|
25
63
|
});
|
|
64
|
+
|
|
65
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
66
|
+
const encodedParams = params
|
|
67
|
+
.filter(param => encodeURIComponent(param.slug) !== param.slug)
|
|
68
|
+
.map(param => ({ ...param, slug: encodeURIComponent(param.slug) }))
|
|
69
|
+
.filter(param => {
|
|
70
|
+
const key = `${param.slug}:${param.page}`;
|
|
71
|
+
if (seen.has(key)) return false;
|
|
72
|
+
seen.add(key);
|
|
73
|
+
return true;
|
|
74
|
+
});
|
|
75
|
+
params.push(...encodedParams);
|
|
76
|
+
}
|
|
77
|
+
|
|
26
78
|
if (params.length === 0) return [{ slug: '_', page: '2' }];
|
|
27
79
|
return params;
|
|
28
80
|
}
|
|
@@ -31,7 +83,17 @@ export const dynamicParams = false;
|
|
|
31
83
|
|
|
32
84
|
export async function generateMetadata({ params }: { params: Promise<{ slug: string; page: string }> }): Promise<Metadata> {
|
|
33
85
|
const { slug: rawSlug, page } = await params;
|
|
34
|
-
const slug =
|
|
86
|
+
const slug = safeDecodeParam(rawSlug);
|
|
87
|
+
const currentPath = `${getSeriesListUrl()}/${slug}`;
|
|
88
|
+
const redirect = findSeriesByRedirectFrom(currentPath);
|
|
89
|
+
if (redirect) {
|
|
90
|
+
const siteUrl = siteConfig.baseUrl.replace(/\/+$/, '');
|
|
91
|
+
return {
|
|
92
|
+
title: redirect.data.title,
|
|
93
|
+
alternates: { canonical: `${siteUrl}${getSeriesListUrl()}/${redirect.slug}/page/${page}` },
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
35
97
|
const seriesData = getSeriesData(slug);
|
|
36
98
|
const title = seriesData?.title || slug;
|
|
37
99
|
const allPosts = seriesData?.type === 'collection' ? getCollectionPosts(slug) : getSeriesPosts(slug);
|
|
@@ -43,8 +105,14 @@ export async function generateMetadata({ params }: { params: Promise<{ slug: str
|
|
|
43
105
|
|
|
44
106
|
export default async function SeriesPage({ params }: { params: Promise<{ slug: string; page: string }> }) {
|
|
45
107
|
const { slug: rawSlug, page: pageStr } = await params;
|
|
46
|
-
const slug =
|
|
108
|
+
const slug = safeDecodeParam(rawSlug);
|
|
47
109
|
const page = parseInt(pageStr);
|
|
110
|
+
const currentPath = `${getSeriesListUrl()}/${slug}`;
|
|
111
|
+
const redirect = findSeriesByRedirectFrom(currentPath);
|
|
112
|
+
if (redirect) {
|
|
113
|
+
return <RedirectPage to={`${getSeriesListUrl()}/${redirect.slug}/page/${page}`} />;
|
|
114
|
+
}
|
|
115
|
+
|
|
48
116
|
const seriesData = getSeriesData(slug);
|
|
49
117
|
const isCollection = seriesData?.type === 'collection';
|
|
50
118
|
const allPosts = isCollection ? getCollectionPosts(slug) : getSeriesPosts(slug);
|