@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.
Files changed (91) hide show
  1. package/.github/workflows/ci.yml +1 -1
  2. package/.github/workflows/publish.yml +2 -2
  3. package/CHANGELOG.md +32 -0
  4. package/GEMINI.md +9 -1
  5. package/README.md +36 -2
  6. package/README.zh.md +36 -2
  7. package/TODO.md +10 -0
  8. package/bun.lock +123 -91
  9. package/content/flows/2026/03/05.md +1 -0
  10. package/content/flows/2026/03/07.md +2 -0
  11. package/content/series/modern-web-dev/index.mdx +4 -2
  12. package/content/series/rst-legacy/deeper-notes/images/test.svg +4 -0
  13. package/content/series/rst-legacy/deeper-notes/index.rst +15 -0
  14. package/content/series/rst-legacy/getting-started.rst +24 -0
  15. package/content/series/rst-legacy/index.rst +9 -0
  16. package/content/series/rst-readme/README.rst +9 -0
  17. package/content/series/rst-readme/readme-index-post.rst +10 -0
  18. package/content/series/rst-toctree/first-post.rst +6 -0
  19. package/content/series/rst-toctree/index.rst +10 -0
  20. package/content/series/rst-toctree/second-post.rst +6 -0
  21. package/content/series/rst-toctree-precedence/first-post.rst +6 -0
  22. package/content/series/rst-toctree-precedence/index.rst +12 -0
  23. package/content/series/rst-toctree-precedence/second-post.rst +6 -0
  24. package/docs/ARCHITECTURE.md +30 -4
  25. package/docs/CONTRIBUTING.md +11 -0
  26. package/docs/DIGITAL_GARDEN.md +22 -1
  27. package/eslint.config.mjs +2 -0
  28. package/next.config.ts +2 -2
  29. package/package.json +27 -21
  30. package/packages/create-amytis/package.json +1 -1
  31. package/packages/create-amytis/src/index.test.ts +43 -1
  32. package/packages/create-amytis/src/index.ts +64 -8
  33. package/public/next-image-export-optimizer-hashes.json +14 -73
  34. package/scripts/build-pagefind.ts +172 -0
  35. package/scripts/copy-assets.ts +246 -56
  36. package/scripts/generate-knowledge-graph.ts +2 -1
  37. package/scripts/new-flow.ts +1 -0
  38. package/scripts/render-rst.py +719 -0
  39. package/scripts/run-with-rst-python.ts +42 -0
  40. package/src/app/[slug]/[postSlug]/page.tsx +20 -10
  41. package/src/app/[slug]/page/[page]/page.tsx +15 -0
  42. package/src/app/all.atom/route.ts +7 -0
  43. package/src/app/all.xml/route.ts +7 -0
  44. package/src/app/archive/page.tsx +7 -4
  45. package/src/app/feed.atom/route.ts +2 -57
  46. package/src/app/feed.xml/route.ts +2 -64
  47. package/src/app/flows/[year]/[month]/[day]/page.tsx +13 -0
  48. package/src/app/flows/feed.atom/route.ts +7 -0
  49. package/src/app/flows/feed.xml/route.ts +7 -0
  50. package/src/app/globals.css +165 -0
  51. package/src/app/page.tsx +1 -0
  52. package/src/app/posts/feed.atom/route.ts +9 -0
  53. package/src/app/posts/feed.xml/route.ts +9 -0
  54. package/src/app/series/[slug]/page/[page]/page.tsx +74 -6
  55. package/src/app/series/[slug]/page.tsx +11 -13
  56. package/src/app/series/page.tsx +3 -3
  57. package/src/components/AuthorCard.tsx +25 -16
  58. package/src/components/CoverImage.tsx +5 -2
  59. package/src/components/FlowCalendarSidebar.tsx +1 -1
  60. package/src/components/FlowContent.tsx +2 -1
  61. package/src/components/FlowTimelineEntry.tsx +7 -1
  62. package/src/components/Footer.tsx +1 -1
  63. package/src/components/MarkdownRenderer.test.tsx +22 -0
  64. package/src/components/MarkdownRenderer.tsx +22 -17
  65. package/src/components/Navbar.tsx +1 -1
  66. package/src/components/PostSidebar.tsx +1 -1
  67. package/src/components/RecentNotesSection.tsx +4 -0
  68. package/src/components/RstRenderer.test.tsx +93 -0
  69. package/src/components/RstRenderer.tsx +122 -0
  70. package/src/layouts/PostLayout.tsx +5 -1
  71. package/src/layouts/SimpleLayout.tsx +10 -3
  72. package/src/lib/feed-utils.ts +158 -18
  73. package/src/lib/image-utils.test.ts +19 -0
  74. package/src/lib/image-utils.ts +11 -0
  75. package/src/lib/markdown.test.ts +140 -2
  76. package/src/lib/markdown.ts +747 -214
  77. package/src/lib/rehype-image-metadata.ts +2 -2
  78. package/src/lib/rst-renderer.test.ts +355 -0
  79. package/src/lib/rst-renderer.ts +617 -0
  80. package/src/lib/rst.test.ts +140 -0
  81. package/src/lib/rst.ts +470 -0
  82. package/src/lib/series-redirects.ts +42 -0
  83. package/tests/e2e/navigation.test.ts +26 -0
  84. package/tests/integration/collections.test.ts +17 -2
  85. package/tests/integration/feed-utils.test.ts +65 -0
  86. package/tests/integration/flow-title.test.ts +53 -0
  87. package/tests/integration/reading-time-headings.test.ts +5 -9
  88. package/tests/integration/series-draft.test.ts +16 -2
  89. package/tests/integration/series.test.ts +93 -0
  90. package/tests/tooling/build-pagefind.test.ts +66 -0
  91. 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 postSlugs
71
- // under `output: "export"` — dev server may receive percent-encoded forms of Unicode paths.
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 key = `${p.slug}/${encodedPostSlug}`;
78
- if (!existing.has(key)) {
79
- existing.add(key);
80
- params.push({ slug: p.slug, postSlug: encodedPostSlug });
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 currentPath = `/${safeDecodeParam(prefix)}/${safeDecodeParam(rawPostSlug)}`;
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 = prefix === basePath && basePath !== 'posts';
172
- const matchedSeriesSlug = Object.entries(customPaths).find(([, path]) => path === prefix)?.[0];
173
- const isAutoSeriesPath = getSeriesAutoPaths() && !Object.hasOwn(customPaths, prefix) && getSeriesData(prefix) !== null;
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' }];
@@ -0,0 +1,7 @@
1
+ import { generateAtomFeed } from '@/lib/feed-utils';
2
+
3
+ export const dynamic = 'force-static';
4
+
5
+ export async function GET() {
6
+ return generateAtomFeed('all', '/all.atom');
7
+ }
@@ -0,0 +1,7 @@
1
+ import { generateRssFeed } from '@/lib/feed-utils';
2
+
3
+ export const dynamic = 'force-static';
4
+
5
+ export async function GET() {
6
+ return generateRssFeed('all', '/all.xml');
7
+ }
@@ -128,12 +128,12 @@ export default function ArchivePage() {
128
128
  <li key={post.slug} className="group">
129
129
  <Link href={getPostUrl(post)} className="block no-underline">
130
130
  <div className="flex flex-col sm:flex-row sm:items-baseline justify-between gap-2 sm:gap-6">
131
- <div className="flex items-baseline gap-6">
131
+ <div className="flex items-baseline gap-6 min-w-0 flex-1">
132
132
  <span className="font-mono text-base text-muted shrink-0 w-8">
133
133
  {day}
134
134
  </span>
135
- <div className="flex items-baseline gap-2">
136
- <h4 className="text-xl font-serif font-medium text-heading/80 group-hover:text-accent transition-colors duration-200">
135
+ <div className="flex items-baseline gap-2 min-w-0 flex-1">
136
+ <h4 className="text-xl font-serif font-medium text-heading/80 group-hover:text-accent transition-colors duration-200 truncate">
137
137
  {post.title}
138
138
  </h4>
139
139
  {post.series && (
@@ -147,7 +147,10 @@ export default function ArchivePage() {
147
147
  </div>
148
148
  </div>
149
149
  {showAuthors && post.authors.length > 0 && (
150
- <span className="text-sm font-sans italic text-muted/60 shrink-0 hidden sm:block">
150
+ <span
151
+ title={post.authors.join(', ')}
152
+ className="text-sm font-sans italic text-muted/60 hidden sm:block max-w-[12rem] md:max-w-[16rem] lg:max-w-[20rem] truncate text-right shrink-0"
153
+ >
151
154
  {post.authors.join(', ')}
152
155
  </span>
153
156
  )}
@@ -1,62 +1,7 @@
1
- import { siteConfig } from '../../../site.config';
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
9
- .replace(/"/g, '&quot;').replace(/'/g, '&apos;');
10
-
11
- const escapeCdata = (v: string) => v.replace(/]]>/g, ']]]]><![CDATA[>');
12
-
13
5
  export async function GET() {
14
- const { format, content: contentMode } = siteConfig.feed;
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 { siteConfig } from '../../../site.config';
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
9
- .replace(/"/g, '&quot;').replace(/'/g, '&apos;');
10
-
11
- const escapeCdata = (v: string) => v.replace(/]]>/g, ']]]]><![CDATA[>');
12
-
13
5
  export async function GET() {
14
- const { format, content: contentMode } = siteConfig.feed;
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>
@@ -0,0 +1,7 @@
1
+ import { generateAtomFeed } from '@/lib/feed-utils';
2
+
3
+ export const dynamic = 'force-static';
4
+
5
+ export async function GET() {
6
+ return generateAtomFeed('flows', '/flows/feed.atom');
7
+ }
@@ -0,0 +1,7 @@
1
+ import { generateRssFeed } from '@/lib/feed-utils';
2
+
3
+ export const dynamic = 'force-static';
4
+
5
+ export async function GET() {
6
+ return generateRssFeed('flows', '/flows/feed.xml');
7
+ }
@@ -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
@@ -96,6 +96,7 @@ export default function Home() {
96
96
  ? recentFlows.map(f => ({
97
97
  slug: f.slug,
98
98
  date: f.date,
99
+ title: f.title,
99
100
  excerpt: f.excerpt,
100
101
  }))
101
102
  : [];
@@ -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
- for (let i = 2; i <= totalPages; i++) {
22
- params.push({ slug, page: i.toString() });
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 = decodeURIComponent(rawSlug);
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 = decodeURIComponent(rawSlug);
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);