@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.
Files changed (78) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/GEMINI.md +9 -1
  3. package/README.md +26 -17
  4. package/README.zh.md +180 -100
  5. package/bun.lock +78 -74
  6. package/content/books/notes-on-thinking/cost-of-certainty.mdx +9 -0
  7. package/content/books/notes-on-thinking/index.mdx +16 -0
  8. package/content/books/notes-on-thinking/mental-models.mdx +9 -0
  9. package/content/books/the-pragmatic-writer/finding-your-voice.mdx +9 -0
  10. package/content/books/the-pragmatic-writer/index.mdx +18 -0
  11. package/content/books/the-pragmatic-writer/the-editing-loop.mdx +9 -0
  12. package/content/books/the-pragmatic-writer/why-writing-matters.mdx +9 -0
  13. package/content/flows/2026/03/01.md +9 -0
  14. package/content/flows/2026/03/03.md +9 -0
  15. package/content/flows/2026/03/05.md +10 -0
  16. package/content/flows/2026/03/07.md +11 -0
  17. package/content/posts/images/vibrant-waves.jpg +0 -0
  18. package/content/posts/welcome-to-amytis.mdx +3 -0
  19. package/content/series/markdown-showcase/index.mdx +2 -1
  20. package/content/series/markdown-showcase/mathematical-notation.mdx +8 -4
  21. package/content/series/markdown-showcase/syntax-highlighting.mdx +9 -5
  22. package/content/series/markdown-showcase/visuals-and-diagrams.mdx +8 -4
  23. 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
  24. package/content/series/modern-web-dev/index.mdx +4 -2
  25. package/docs/ARCHITECTURE.md +8 -1
  26. package/docs/DIGITAL_GARDEN.md +22 -1
  27. package/package.json +12 -12
  28. package/public/next-image-export-optimizer-hashes.json +3 -2
  29. package/scripts/new-flow.ts +1 -0
  30. package/site.config.example.ts +3 -4
  31. package/site.config.ts +6 -7
  32. package/src/app/[slug]/[postSlug]/page.tsx +19 -2
  33. package/src/app/[slug]/page/[page]/page.tsx +26 -5
  34. package/src/app/[slug]/page.tsx +28 -8
  35. package/src/app/all.atom/route.ts +7 -0
  36. package/src/app/all.xml/route.ts +7 -0
  37. package/src/app/archive/page.tsx +7 -4
  38. package/src/app/feed.atom/route.ts +2 -57
  39. package/src/app/feed.xml/route.ts +2 -64
  40. package/src/app/flows/[year]/[month]/[day]/page.tsx +13 -0
  41. package/src/app/flows/feed.atom/route.ts +7 -0
  42. package/src/app/flows/feed.xml/route.ts +7 -0
  43. package/src/app/page.tsx +1 -2
  44. package/src/app/posts/[slug]/page.tsx +28 -9
  45. package/src/app/posts/feed.atom/route.ts +9 -0
  46. package/src/app/posts/feed.xml/route.ts +9 -0
  47. package/src/app/series/[slug]/page.tsx +46 -4
  48. package/src/components/CuratedSeriesSection.tsx +7 -11
  49. package/src/components/FeaturedStoriesSection.tsx +1 -1
  50. package/src/components/FlowCalendarSidebar.tsx +1 -1
  51. package/src/components/FlowContent.tsx +2 -1
  52. package/src/components/FlowTimelineEntry.tsx +7 -1
  53. package/src/components/Footer.tsx +6 -6
  54. package/src/components/HorizontalScroll.tsx +5 -14
  55. package/src/components/MarkdownRenderer.test.tsx +6 -0
  56. package/src/components/MarkdownRenderer.tsx +18 -16
  57. package/src/components/Navbar.tsx +1 -1
  58. package/src/components/PostList.tsx +20 -36
  59. package/src/components/PostSidebar.tsx +1 -1
  60. package/src/components/RecentNotesSection.tsx +4 -0
  61. package/src/components/SelectedBooksSection.tsx +65 -25
  62. package/src/components/SeriesCatalog.tsx +9 -7
  63. package/src/i18n/translations.ts +2 -0
  64. package/src/layouts/PostLayout.tsx +1 -1
  65. package/src/layouts/SimpleLayout.tsx +3 -3
  66. package/src/lib/feed-utils.ts +158 -18
  67. package/src/lib/markdown.ts +26 -5
  68. package/src/lib/urls.ts +9 -4
  69. package/tests/e2e/mobile/mobile-compat.spec.ts +58 -0
  70. package/tests/e2e/navigation.test.ts +26 -0
  71. package/tests/integration/collections.test.ts +17 -2
  72. package/tests/integration/feed-utils.test.ts +52 -0
  73. package/tests/integration/flow-title.test.ts +53 -0
  74. package/tests/integration/markdown-features.test.ts +3 -3
  75. package/tests/integration/reading-time-headings.test.ts +2 -2
  76. package/tests/unit/static-params.test.ts +155 -22
  77. package/tests/unit/urls.test.ts +10 -12
  78. /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 { 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
+ }
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 post = resolvePostFromParam(rawSlug);
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 post = resolvePostFromParam(rawSlug);
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 that opted in via redirectFrom.
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
- if (slugs.length === 0) return [{ slug: '_' }];
18
- return slugs.map((slug) => ({ slug }));
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, scrollThreshold }: CuratedSeriesSectionProps) {
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 transition-colors focus:outline-none"
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="/series" className="text-sm text-muted hover:text-accent transition-colors no-underline">
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
- itemCount={displayed.length}
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 > scrollThreshold
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 transition-colors focus:outline-none"
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: string;
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 shouldShowArrows = itemCount > scrollThreshold;
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 - positioned outside content in margin area */}
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 - positioned outside content in margin area */}
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
  });