@hutusi/amytis 1.9.0 → 1.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/.entire/settings.json +4 -0
  2. package/AGENTS.md +13 -7
  3. package/CHANGELOG.md +41 -0
  4. package/CLAUDE.md +30 -2
  5. package/README.md +84 -58
  6. package/README.zh.md +195 -0
  7. package/TODO.md +6 -4
  8. package/bun.lock +30 -4
  9. package/content/books/sample-book/images/digital-garden.svg +25 -0
  10. package/content/books/sample-book/index.mdx +1 -1
  11. package/content/books/sample-book/introduction.mdx +2 -0
  12. package/content/posts/2026-01-21-kitchen-sink/index.mdx +26 -1
  13. package/content/posts/legacy-markdown.md +0 -2
  14. package/content/posts/markdown-features.mdx +1 -1
  15. package/content/posts/multilingual-test.mdx +0 -1
  16. package/content/posts/syntax-highlighting-showcase.mdx +1 -0
  17. package/content/posts/understanding-react-hooks.mdx +1 -0
  18. package/content/series/ai-nexus-weekly/index.mdx +1 -1
  19. package/content/series/digital-garden/01-philosophy.mdx +2 -0
  20. package/content/series/digital-garden/index.mdx +1 -1
  21. package/content/series/markdown-showcase/index.mdx +1 -1
  22. package/content/series/markdown-showcase/syntax-highlighting.mdx +2 -0
  23. package/content/series/nextjs-deep-dive/01-getting-started.mdx +2 -1
  24. package/content/series/nextjs-deep-dive/index.mdx +1 -1
  25. package/content/subscribe.mdx +27 -0
  26. package/docs/ARCHITECTURE.md +85 -105
  27. package/docs/CONTRIBUTING.md +11 -3
  28. package/docs/DIGITAL_GARDEN.md +11 -9
  29. package/docs/deployment.md +31 -13
  30. package/eslint.config.mjs +2 -0
  31. package/package.json +13 -4
  32. package/packages/create-amytis/package.json +13 -0
  33. package/packages/create-amytis/src/index.test.ts +187 -0
  34. package/packages/create-amytis/src/index.ts +230 -0
  35. package/packages/create-amytis/tsconfig.json +14 -0
  36. package/public/images/amytis-screenshot.jpg +0 -0
  37. package/public/images/antelope-canyon.jpg +0 -0
  38. package/public/images/avatar.jpg +0 -0
  39. package/public/images/cappadocia.jpg +0 -0
  40. package/public/images/flowers.jpg +0 -0
  41. package/public/images/galaxy.jpg +0 -0
  42. package/public/images/lake.jpg +0 -0
  43. package/public/images/mountains.jpg +0 -0
  44. package/public/images/vibrant-waves.avif +0 -0
  45. package/public/images/wechat-qr.jpg +0 -0
  46. package/public/next-image-export-optimizer-hashes.json +72 -2
  47. package/scripts/deploy.ts +77 -0
  48. package/site.config.example.ts +268 -0
  49. package/site.config.ts +82 -10
  50. package/src/app/[slug]/[postSlug]/page.tsx +161 -0
  51. package/src/app/[slug]/page/[page]/page.tsx +202 -0
  52. package/src/app/[slug]/page.tsx +162 -7
  53. package/src/app/archive/page.tsx +5 -8
  54. package/src/app/books/[slug]/[chapter]/page.tsx +45 -3
  55. package/src/app/books/[slug]/page.tsx +16 -2
  56. package/src/app/books/page.tsx +12 -8
  57. package/src/app/feed.atom/route.ts +62 -0
  58. package/src/app/feed.xml/route.ts +45 -20
  59. package/src/app/flows/[year]/[month]/[day]/page.tsx +9 -1
  60. package/src/app/flows/[year]/page.tsx +1 -1
  61. package/src/app/flows/page/[page]/page.tsx +2 -0
  62. package/src/app/flows/page.tsx +4 -0
  63. package/src/app/globals.css +9 -3
  64. package/src/app/layout.tsx +22 -1
  65. package/src/app/notes/[slug]/page.tsx +2 -3
  66. package/src/app/page.tsx +86 -8
  67. package/src/app/posts/[slug]/page.tsx +33 -7
  68. package/src/app/posts/page/[page]/page.tsx +9 -5
  69. package/src/app/posts/page.tsx +13 -7
  70. package/src/app/series/[slug]/page/[page]/page.tsx +8 -25
  71. package/src/app/series/[slug]/page.tsx +21 -19
  72. package/src/app/series/page.tsx +14 -28
  73. package/src/app/sitemap.ts +2 -1
  74. package/src/app/tags/[tag]/page.tsx +1 -1
  75. package/src/app/tags/page.tsx +1 -1
  76. package/src/components/Analytics.tsx +34 -36
  77. package/src/components/AuthorCard.tsx +56 -14
  78. package/src/components/BookMobileNav.tsx +8 -34
  79. package/src/components/BookSidebar.tsx +17 -107
  80. package/src/components/CodeBlock.test.tsx +19 -0
  81. package/src/components/CodeBlock.tsx +20 -16
  82. package/src/components/Comments.tsx +4 -3
  83. package/src/components/CoverImage.tsx +11 -3
  84. package/src/components/CuratedSeriesSection.tsx +17 -15
  85. package/src/components/ExternalLinks.tsx +1 -1
  86. package/src/components/FeaturedStoriesSection.tsx +124 -58
  87. package/src/components/FlowCalendarSidebar.tsx +10 -7
  88. package/src/components/FlowContent.tsx +33 -5
  89. package/src/components/Footer.tsx +28 -6
  90. package/src/components/Hero.tsx +44 -92
  91. package/src/components/InlineBookToc.tsx +56 -0
  92. package/src/components/KnowledgeGraph.tsx +26 -0
  93. package/src/components/LanguageProvider.tsx +7 -2
  94. package/src/components/LanguageSwitch.tsx +1 -1
  95. package/src/components/LatestWritingSection.tsx +12 -22
  96. package/src/components/MarkdownRenderer.test.tsx +41 -0
  97. package/src/components/MarkdownRenderer.tsx +43 -8
  98. package/src/components/Navbar.tsx +24 -15
  99. package/src/components/NoteSidebar.tsx +6 -76
  100. package/src/components/Pagination.tsx +2 -2
  101. package/src/components/PostCard.tsx +2 -1
  102. package/src/components/PostList.tsx +81 -62
  103. package/src/components/PostNavigation.tsx +8 -7
  104. package/src/components/PostSidebar.tsx +12 -107
  105. package/src/components/PrevNextNav.tsx +63 -0
  106. package/src/components/RecentNotesSection.tsx +2 -2
  107. package/src/components/RelatedPosts.tsx +3 -2
  108. package/src/components/RssFeedWidget.tsx +55 -0
  109. package/src/components/SectionLabel.tsx +16 -0
  110. package/src/components/SelectedBooksSection.tsx +12 -15
  111. package/src/components/SeriesCatalog.tsx +74 -69
  112. package/src/components/SeriesList.tsx +11 -36
  113. package/src/components/SeriesSidebar.tsx +4 -2
  114. package/src/components/TagContentTabs.tsx +17 -12
  115. package/src/components/TagPageHeader.tsx +12 -2
  116. package/src/components/TagSidebar.tsx +3 -2
  117. package/src/components/TagsIndexClient.tsx +1 -1
  118. package/src/components/TocPanel.tsx +67 -0
  119. package/src/hooks/useActiveHeading.ts +32 -0
  120. package/src/hooks/useSidebarAutoScroll.ts +19 -0
  121. package/src/i18n/translations.ts +26 -4
  122. package/src/layouts/BookLayout.tsx +10 -40
  123. package/src/layouts/PostLayout.tsx +37 -26
  124. package/src/layouts/SimpleLayout.tsx +5 -5
  125. package/src/lib/feed-utils.ts +68 -0
  126. package/src/lib/format-utils.test.ts +27 -0
  127. package/src/lib/format-utils.ts +3 -0
  128. package/src/lib/image-utils.ts +12 -0
  129. package/src/lib/json-ld.ts +236 -0
  130. package/src/lib/markdown.ts +62 -7
  131. package/src/lib/rehype-image-metadata.test.ts +124 -0
  132. package/src/lib/rehype-image-metadata.ts +21 -5
  133. package/src/lib/scroll-utils.ts +11 -0
  134. package/src/lib/shuffle.ts +21 -0
  135. package/src/lib/urls.ts +51 -0
  136. package/tests/e2e/navigation.test.ts +15 -1
  137. package/tests/integration/books.test.ts +61 -0
  138. package/tests/integration/feed-utils.test.ts +145 -0
  139. package/tests/integration/series.test.ts +9 -0
  140. package/tests/unit/static-params.test.ts +59 -2
  141. package/src/app/subscribe/page.tsx +0 -17
  142. package/src/components/SubscribePage.tsx +0 -298
  143. /package/public/{screenshot.png → images/screenshot.png} +0 -0
@@ -1,16 +1,18 @@
1
- import { getAllPosts } from '@/lib/markdown';
1
+ import { getListingPosts } from '@/lib/markdown';
2
2
  import PostList from '@/components/PostList';
3
3
  import Pagination from '@/components/Pagination';
4
4
  import { siteConfig } from '../../../../../site.config';
5
5
  import { Metadata } from 'next';
6
6
  import { notFound } from 'next/navigation';
7
- import { t, resolveLocale } from '@/lib/i18n';
7
+ import { t, resolveLocale, tWith } from '@/lib/i18n';
8
8
  import PageHeader from '@/components/PageHeader';
9
+ import { getPostsBasePath } from '@/lib/urls';
9
10
 
10
11
  const PAGE_SIZE = siteConfig.pagination.posts;
11
12
 
12
13
  export function generateStaticParams() {
13
- const allPosts = getAllPosts();
14
+ if (getPostsBasePath() !== 'posts') return [{ page: '_' }]; // Route disabled; custom path handles this
15
+ const allPosts = getListingPosts();
14
16
  const totalPages = Math.ceil(allPosts.length / PAGE_SIZE);
15
17
 
16
18
  // Generate params for page 2 to totalPages (page 1 is handled by /posts/page.tsx)
@@ -25,15 +27,17 @@ export const dynamicParams = false;
25
27
 
26
28
  export async function generateMetadata({ params }: { params: Promise<{ page: string }> }): Promise<Metadata> {
27
29
  const { page } = await params;
30
+ const allPosts = getListingPosts();
31
+ const totalPages = Math.ceil(allPosts.length / PAGE_SIZE);
28
32
  return {
29
- title: `${t('posts')} - ${page} | ${resolveLocale(siteConfig.title)}`,
33
+ title: `${t('posts')} - ${tWith('page_of_total', { page: parseInt(page), total: totalPages })} | ${resolveLocale(siteConfig.title)}`,
30
34
  };
31
35
  }
32
36
 
33
37
  export default async function PostsPage({ params }: { params: Promise<{ page: string }> }) {
34
38
  const { page: pageStr } = await params;
35
39
  const page = parseInt(pageStr);
36
- const allPosts = getAllPosts();
40
+ const allPosts = getListingPosts();
37
41
  const totalPages = Math.ceil(allPosts.length / PAGE_SIZE);
38
42
 
39
43
  if (isNaN(page) || page < 2 || page > totalPages) notFound();
@@ -1,20 +1,26 @@
1
- import { getAllPosts } from '@/lib/markdown';
1
+ import { getListingPosts } from '@/lib/markdown';
2
2
  import PostList from '@/components/PostList';
3
3
  import Pagination from '@/components/Pagination';
4
4
  import { siteConfig } from '../../../site.config';
5
5
  import { Metadata } from 'next';
6
- import { t, resolveLocale } from '@/lib/i18n';
6
+ import { t, resolveLocale, tWith } from '@/lib/i18n';
7
7
  import PageHeader from '@/components/PageHeader';
8
+ import { getPostsBasePath } from '@/lib/urls';
9
+ import { notFound } from 'next/navigation';
8
10
 
9
11
  const PAGE_SIZE = siteConfig.pagination.posts;
10
12
 
11
- export const metadata: Metadata = {
12
- title: `${t('posts')} | ${resolveLocale(siteConfig.title)}`,
13
- description: 'Browse the complete archive of articles.',
14
- };
13
+ export async function generateMetadata(): Promise<Metadata> {
14
+ const allPosts = getListingPosts();
15
+ return {
16
+ title: `${t('posts')} | ${resolveLocale(siteConfig.title)}`,
17
+ description: tWith('posts_subtitle', { count: allPosts.length }),
18
+ };
19
+ }
15
20
 
16
21
  export default function AllPostsPage() {
17
- const allPosts = getAllPosts();
22
+ if (getPostsBasePath() !== 'posts') notFound();
23
+ const allPosts = getListingPosts();
18
24
  const page = 1;
19
25
  const totalPages = Math.ceil(allPosts.length / PAGE_SIZE);
20
26
  const posts = allPosts.slice(0, PAGE_SIZE);
@@ -1,4 +1,4 @@
1
- import { getSeriesData, getSeriesPosts, getAllSeries, getSeriesAuthors, getAuthorSlug } from '@/lib/markdown';
1
+ import { getSeriesData, getSeriesPosts, getAllSeries, resolveSeriesAuthors, getAuthorSlug } from '@/lib/markdown';
2
2
  import { notFound } from 'next/navigation';
3
3
  import SeriesCatalog from '@/components/SeriesCatalog';
4
4
  import Pagination from '@/components/Pagination';
@@ -6,7 +6,7 @@ import { Metadata } from 'next';
6
6
  import { siteConfig } from '../../../../../../site.config';
7
7
  import CoverImage from '@/components/CoverImage';
8
8
  import Link from 'next/link';
9
- import { t, resolveLocale } from '@/lib/i18n';
9
+ import { t, resolveLocale, tWith } from '@/lib/i18n';
10
10
 
11
11
  const PAGE_SIZE = siteConfig.pagination.series;
12
12
 
@@ -34,8 +34,10 @@ export async function generateMetadata({ params }: { params: Promise<{ slug: str
34
34
  const slug = decodeURIComponent(rawSlug);
35
35
  const seriesData = getSeriesData(slug);
36
36
  const title = seriesData?.title || slug;
37
+ const allPosts = getSeriesPosts(slug);
38
+ const totalPages = Math.ceil(allPosts.length / PAGE_SIZE);
37
39
  return {
38
- title: `${title} - ${page} | ${resolveLocale(siteConfig.title)}`,
40
+ title: `${title} - ${tWith('page_of_total', { page, total: totalPages })} | ${resolveLocale(siteConfig.title)}`,
39
41
  };
40
42
  }
41
43
 
@@ -61,24 +63,7 @@ export default async function SeriesPage({ params }: { params: Promise<{ slug: s
61
63
  const description = seriesData?.excerpt;
62
64
  const coverImage = seriesData?.coverImage;
63
65
 
64
- // Use explicitly configured series authors, or aggregate top authors from posts
65
- const explicitAuthors = getSeriesAuthors(slug);
66
- let authors: string[];
67
- if (explicitAuthors) {
68
- authors = explicitAuthors;
69
- } else if (allPosts.length > 0) {
70
- const counts = new Map<string, number>();
71
- for (const post of allPosts) {
72
- for (const author of post.authors) {
73
- counts.set(author, (counts.get(author) || 0) + 1);
74
- }
75
- }
76
- authors = [...counts.entries()]
77
- .sort((a, b) => b[1] - a[1])
78
- .map(([name]) => name);
79
- } else {
80
- authors = [];
81
- }
66
+ const authors = resolveSeriesAuthors(slug, allPosts);
82
67
 
83
68
  // Calculate the starting index for this page
84
69
  const startIndex = (page - 1) * PAGE_SIZE;
@@ -103,10 +88,8 @@ export default async function SeriesPage({ params }: { params: Promise<{ slug: s
103
88
  <span className="badge-accent mb-4">
104
89
  {t('series')} • {allPosts.length} {t('parts')}
105
90
  </span>
106
- <h1 className="page-title mb-4">
107
- {title}
108
- <span className="block text-lg text-muted font-sans font-normal mt-2">{page} / {totalPages}</span>
109
- </h1>
91
+ <h1 className="page-title mb-2">{title}</h1>
92
+ <p className="text-base text-muted font-sans mt-1 mb-4">{tWith('page_of_total', { page, total: totalPages })}</p>
110
93
  {description && (
111
94
  <p className="text-lg text-muted font-serif italic leading-relaxed">
112
95
  {description}
@@ -1,4 +1,4 @@
1
- import { getSeriesData, getSeriesPosts, getAllSeries, getSeriesAuthors, getAuthorSlug } from '@/lib/markdown';
1
+ import { getSeriesData, getSeriesPosts, getAllSeries, resolveSeriesAuthors, getAuthorSlug } from '@/lib/markdown';
2
2
  import { notFound } from 'next/navigation';
3
3
  import SeriesCatalog from '@/components/SeriesCatalog';
4
4
  import Pagination from '@/components/Pagination';
@@ -7,6 +7,7 @@ import { siteConfig } from '../../../../site.config';
7
7
  import CoverImage from '@/components/CoverImage';
8
8
  import Link from 'next/link';
9
9
  import { t, resolveLocale } from '@/lib/i18n';
10
+ import { getPostUrl } from '@/lib/urls';
10
11
 
11
12
  const PAGE_SIZE = siteConfig.pagination.series;
12
13
 
@@ -78,24 +79,7 @@ export default async function SeriesPage({ params }: { params: Promise<{ slug: s
78
79
  const title = seriesData?.title || slug.charAt(0).toUpperCase() + slug.slice(1);
79
80
  const description = seriesData?.excerpt;
80
81
  const coverImage = seriesData?.coverImage;
81
- // Use explicitly configured series authors, or aggregate top authors from posts
82
- const explicitAuthors = getSeriesAuthors(slug);
83
- let authors: string[];
84
- if (explicitAuthors) {
85
- authors = explicitAuthors;
86
- } else if (allPosts.length > 0) {
87
- const counts = new Map<string, number>();
88
- for (const post of allPosts) {
89
- for (const author of post.authors) {
90
- counts.set(author, (counts.get(author) || 0) + 1);
91
- }
92
- }
93
- authors = [...counts.entries()]
94
- .sort((a, b) => b[1] - a[1])
95
- .map(([name]) => name);
96
- } else {
97
- authors = [];
98
- }
82
+ const authors = resolveSeriesAuthors(slug, allPosts);
99
83
 
100
84
  return (
101
85
  <div className="layout-main">
@@ -125,6 +109,24 @@ export default async function SeriesPage({ params }: { params: Promise<{ slug: s
125
109
  {description}
126
110
  </p>
127
111
  )}
112
+ {allPosts.length > 0 && (() => {
113
+ // Pick the first installment respecting sort order:
114
+ // date-desc (default) → oldest is last; date-asc/manual → oldest/first is [0]
115
+ const firstPost = (seriesData?.sort === 'date-asc' || seriesData?.sort === 'manual')
116
+ ? allPosts[0]
117
+ : allPosts[allPosts.length - 1];
118
+ return (
119
+ <Link
120
+ href={getPostUrl(firstPost)}
121
+ className="mt-6 inline-flex items-center gap-2 px-5 py-2.5 rounded-full bg-accent text-white text-sm font-medium hover:bg-accent/90 transition-colors no-underline"
122
+ >
123
+ {t('start_reading')}
124
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
125
+ <polyline points="9 18 15 12 9 6" />
126
+ </svg>
127
+ </Link>
128
+ );
129
+ })()}
128
130
  {authors.length > 0 && (
129
131
  <p className="mt-4 text-sm text-muted">
130
132
  <span className="mr-1">{t('written_by')}</span>
@@ -1,15 +1,19 @@
1
- import { getAllSeries, getSeriesData, getSeriesAuthors } from '@/lib/markdown';
1
+ import { getAllSeries, getSeriesData, resolveSeriesAuthors } from '@/lib/markdown';
2
2
  import Link from 'next/link';
3
3
  import { siteConfig } from '../../../site.config';
4
4
  import { Metadata } from 'next';
5
5
  import CoverImage from '@/components/CoverImage';
6
- import { t, resolveLocale } from '@/lib/i18n';
6
+ import { t, resolveLocale, tWith } from '@/lib/i18n';
7
7
  import PageHeader from '@/components/PageHeader';
8
8
 
9
- export const metadata: Metadata = {
10
- title: `${t('series')} | ${resolveLocale(siteConfig.title)}`,
11
- description: 'Curated collections of articles and thoughts.',
12
- };
9
+ export async function generateMetadata(): Promise<Metadata> {
10
+ const allSeries = getAllSeries();
11
+ const count = Object.keys(allSeries).length;
12
+ return {
13
+ title: `${t('series')} | ${resolveLocale(siteConfig.title)}`,
14
+ description: tWith('series_subtitle', { count }),
15
+ };
16
+ }
13
17
 
14
18
  export default function SeriesIndexPage() {
15
19
  const allSeries = getAllSeries();
@@ -26,7 +30,7 @@ export default function SeriesIndexPage() {
26
30
  return (
27
31
  <div className="layout-main">
28
32
  <PageHeader
29
- titleKey="all_series"
33
+ titleKey="series"
30
34
  subtitleKey="series_subtitle"
31
35
  subtitleOneKey="series_subtitle_one"
32
36
  count={totalSeries}
@@ -38,26 +42,8 @@ export default function SeriesIndexPage() {
38
42
  const posts = allSeries[slug];
39
43
  const seriesData = getSeriesData(slug);
40
44
  const title = seriesData?.title || slug.charAt(0).toUpperCase() + slug.slice(1);
41
- const description = seriesData?.excerpt || `${posts.length} articles in this collection.`;
42
-
43
- // Resolve authors: explicit series authors, then aggregate from posts
44
- const explicitAuthors = getSeriesAuthors(slug);
45
- let authors: string[];
46
- if (explicitAuthors) {
47
- authors = explicitAuthors;
48
- } else if (posts.length > 0) {
49
- const counts = new Map<string, number>();
50
- for (const post of posts) {
51
- for (const author of post.authors) {
52
- counts.set(author, (counts.get(author) || 0) + 1);
53
- }
54
- }
55
- authors = [...counts.entries()]
56
- .sort((a, b) => b[1] - a[1])
57
- .map(([name]) => name);
58
- } else {
59
- authors = [];
60
- }
45
+ const description = seriesData?.excerpt || t('series_default_excerpt');
46
+ const authors = resolveSeriesAuthors(slug, posts);
61
47
 
62
48
  return (
63
49
  <Link key={slug} href={`/series/${slug}`} className="group block no-underline">
@@ -72,7 +58,7 @@ export default function SeriesIndexPage() {
72
58
  </div>
73
59
  <div className="p-8">
74
60
  <span className="badge-accent mb-4 inline-block">
75
- {posts.length} {t('posts')}
61
+ {posts.length} {t('parts')}
76
62
  </span>
77
63
  <h2 className="mb-3 font-serif text-2xl font-bold text-heading group-hover:text-accent transition-colors">
78
64
  {title}
@@ -1,6 +1,7 @@
1
1
  import { MetadataRoute } from 'next';
2
2
  import { getAllPosts, getAllPages, getAllBooks, getAllFlows } from '@/lib/markdown';
3
3
  import { siteConfig } from '../../site.config';
4
+ import { getPostUrl } from '@/lib/urls';
4
5
 
5
6
  export const dynamic = 'force-static';
6
7
 
@@ -12,7 +13,7 @@ export default function sitemap(): MetadataRoute.Sitemap {
12
13
  const baseUrl = siteConfig.baseUrl;
13
14
 
14
15
  const postUrls = posts.map((post) => ({
15
- url: `${baseUrl}/posts/${post.slug}`,
16
+ url: `${baseUrl}${getPostUrl(post)}`,
16
17
  lastModified: post.date,
17
18
  changeFrequency: 'monthly' as const,
18
19
  priority: 0.7,
@@ -50,7 +50,7 @@ export default async function TagPage({
50
50
  <TagSidebar key={decodedTag} tags={allTags} activeTag={decodedTag} />
51
51
 
52
52
  <div className="flex-1 min-w-0">
53
- <TagPageHeader tag={decodedTag} />
53
+ <TagPageHeader tag={decodedTag} postCount={posts.length} flowCount={flows.length} />
54
54
  <TagContentTabs posts={posts} flows={flows} />
55
55
  </div>
56
56
  </div>
@@ -7,7 +7,7 @@ import TagsIndexClient from '@/components/TagsIndexClient';
7
7
 
8
8
  export const metadata: Metadata = {
9
9
  title: `${t('tags')} | ${resolveLocale(siteConfig.title)}`,
10
- description: 'Explore topics in the garden.',
10
+ description: t('tags_description'),
11
11
  };
12
12
 
13
13
  export default function TagsPage() {
@@ -4,46 +4,44 @@ import Script from 'next/script';
4
4
  import { siteConfig } from '../../site.config';
5
5
 
6
6
  export default function Analytics() {
7
- const { provider, umami, plausible, google } = siteConfig.analytics;
7
+ const { providers, umami, plausible, google } = siteConfig.analytics;
8
8
 
9
- if (provider === 'umami' && umami.websiteId) {
10
- return (
11
- <Script
12
- src={umami.src}
13
- data-website-id={umami.websiteId}
14
- strategy="afterInteractive"
15
- />
16
- );
17
- }
9
+ if (!providers || providers.length === 0) return null;
18
10
 
19
- if (provider === 'plausible' && plausible.domain) {
20
- return (
21
- <Script
22
- src={plausible.src}
23
- data-domain={plausible.domain}
24
- strategy="afterInteractive"
25
- />
26
- );
27
- }
28
-
29
- if (provider === 'google' && google.measurementId) {
30
- return (
31
- <>
11
+ return (
12
+ <>
13
+ {providers.includes('umami') && umami.websiteId && (
32
14
  <Script
33
- src={`https://www.googletagmanager.com/gtag/js?id=${google.measurementId}`}
15
+ src={umami.src}
16
+ data-website-id={umami.websiteId}
34
17
  strategy="afterInteractive"
35
18
  />
36
- <Script id="google-analytics" strategy="afterInteractive">
37
- {`
38
- window.dataLayer = window.dataLayer || [];
39
- function gtag(){dataLayer.push(arguments);}
40
- gtag('js', new Date());
41
- gtag('config', '${google.measurementId}');
42
- `}
43
- </Script>
44
- </>
45
- );
46
- }
19
+ )}
47
20
 
48
- return null;
21
+ {providers.includes('plausible') && plausible.domain && (
22
+ <Script
23
+ src={plausible.src}
24
+ data-domain={plausible.domain}
25
+ strategy="afterInteractive"
26
+ />
27
+ )}
28
+
29
+ {providers.includes('google') && google.measurementId && (
30
+ <>
31
+ <Script
32
+ src={`https://www.googletagmanager.com/gtag/js?id=${google.measurementId}`}
33
+ strategy="afterInteractive"
34
+ />
35
+ <Script id="google-analytics" strategy="afterInteractive">
36
+ {`
37
+ window.dataLayer = window.dataLayer || [];
38
+ function gtag(){dataLayer.push(arguments);}
39
+ gtag('js', new Date());
40
+ gtag('config', '${google.measurementId}');
41
+ `}
42
+ </Script>
43
+ </>
44
+ )}
45
+ </>
46
+ );
49
47
  }
@@ -1,30 +1,51 @@
1
1
  import Link from 'next/link';
2
+ import ExportedImage from 'next-image-export-optimizer';
2
3
  import { getAuthorSlug } from '@/lib/markdown';
3
4
  import { siteConfig } from '../../site.config';
4
5
  import { t } from '@/lib/i18n';
5
6
 
7
+ const isDev = process.env.NODE_ENV === 'development';
8
+ const isExternal = (src: string) => src.startsWith('http') || src.startsWith('//');
9
+
6
10
  export default function AuthorCard({ authors }: { authors: string[] }) {
7
11
  if (!authors || authors.length === 0) return null;
8
12
 
9
13
  return (
10
- <div className="mt-12 pt-12 border-t border-muted/20">
11
- <div className="flex flex-col gap-6">
12
- {authors.map((author) => {
13
- const slug = getAuthorSlug(author);
14
- const profile = siteConfig.authors?.[author];
14
+ <div className="mt-12 flex flex-col gap-4">
15
+ {authors.map((author) => {
16
+ const slug = getAuthorSlug(author);
17
+ const profile = siteConfig.authors?.[author];
18
+ const hasSocial = profile?.social && profile.social.length > 0;
19
+
20
+ return (
21
+ <div
22
+ key={author}
23
+ className="flex flex-col sm:flex-row sm:items-center gap-5 rounded-2xl border border-muted/15 bg-muted/[0.04] px-6 py-5 transition-colors hover:bg-muted/[0.08]"
24
+ >
25
+ {/* Left — avatar + author info */}
26
+ <div className="flex items-start gap-4 flex-1 min-w-0">
27
+ {profile?.avatar ? (
28
+ <ExportedImage
29
+ src={profile.avatar}
30
+ alt={author}
31
+ width={56}
32
+ height={56}
33
+ className="w-14 h-14 rounded-full object-cover flex-shrink-0 ring-2 ring-muted/20"
34
+ unoptimized={isDev || isExternal(profile.avatar)}
35
+ />
36
+ ) : (
37
+ <div className="w-14 h-14 rounded-full bg-accent/10 flex items-center justify-center flex-shrink-0 text-accent font-serif font-bold text-2xl select-none">
38
+ {author.charAt(0).toUpperCase()}
39
+ </div>
40
+ )}
15
41
 
16
- return (
17
- <div key={author} className="flex items-start gap-4">
18
- <div className="w-10 h-10 rounded-full bg-accent/10 flex items-center justify-center flex-shrink-0 text-accent font-serif font-bold text-lg select-none">
19
- {author.charAt(0).toUpperCase()}
20
- </div>
21
42
  <div className="min-w-0">
22
43
  <p className="text-[10px] font-sans font-bold uppercase tracking-widest text-muted mb-1">
23
44
  {t('written_by')}
24
45
  </p>
25
46
  <Link
26
47
  href={`/authors/${slug}`}
27
- className="font-serif font-semibold text-heading hover:text-accent transition-colors no-underline"
48
+ className="font-serif font-semibold text-lg text-heading hover:text-accent transition-colors no-underline"
28
49
  >
29
50
  {author}
30
51
  </Link>
@@ -35,9 +56,30 @@ export default function AuthorCard({ authors }: { authors: string[] }) {
35
56
  )}
36
57
  </div>
37
58
  </div>
38
- );
39
- })}
40
- </div>
59
+
60
+ {/* Right — social images (e.g. QR codes) */}
61
+ {hasSocial && (
62
+ <div className="flex justify-center gap-5 flex-shrink-0 border-t border-muted/15 pt-4 sm:border-t-0 sm:border-l sm:pt-0 sm:pl-6 sm:justify-start">
63
+ {profile.social!.map((item, index) => (
64
+ <figure key={index} className="flex flex-col items-center gap-1.5">
65
+ <ExportedImage
66
+ src={item.image}
67
+ alt={item.description}
68
+ width={72}
69
+ height={72}
70
+ className="w-[72px] h-[72px] object-contain rounded-lg bg-white p-0.5"
71
+ unoptimized={isDev || isExternal(item.image)}
72
+ />
73
+ <figcaption className="text-[10px] font-sans text-muted text-center leading-tight max-w-[72px]">
74
+ {item.description}
75
+ </figcaption>
76
+ </figure>
77
+ ))}
78
+ </div>
79
+ )}
80
+ </div>
81
+ );
82
+ })}
41
83
  </div>
42
84
  );
43
85
  }
@@ -4,6 +4,8 @@ import { useState } from 'react';
4
4
  import Link from 'next/link';
5
5
  import { BookTocItem, BookChapterEntry } from '@/lib/markdown';
6
6
  import { useLanguage } from './LanguageProvider';
7
+ import PrevNextNav from './PrevNextNav';
8
+ import { getBookChapterUrl } from '@/lib/urls';
7
9
 
8
10
  interface BookMobileNavProps {
9
11
  bookSlug: string;
@@ -48,39 +50,11 @@ export default function BookMobileNav({ bookSlug, bookTitle, toc, chapters, curr
48
50
  </div>
49
51
 
50
52
  {/* Prev / Next */}
51
- <div className="flex gap-3 mb-3">
52
- {prevChapter ? (
53
- <Link
54
- href={`/books/${bookSlug}/${prevChapter.id}`}
55
- className="flex-1 flex items-center gap-2 py-2.5 px-3 rounded-lg bg-muted/5 hover:bg-muted/10 no-underline transition-colors group"
56
- >
57
- <svg className="w-4 h-4 flex-shrink-0 text-muted group-hover:text-accent transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
58
- <path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
59
- </svg>
60
- <div className="min-w-0">
61
- <span className="block text-[10px] font-sans font-bold uppercase tracking-widest text-muted mb-0.5">{t('prev')}</span>
62
- <span className="block text-sm text-foreground/80 group-hover:text-foreground truncate transition-colors">{prevChapter.title}</span>
63
- </div>
64
- </Link>
65
- ) : (
66
- <div className="flex-1" />
67
- )}
68
- {nextChapter ? (
69
- <Link
70
- href={`/books/${bookSlug}/${nextChapter.id}`}
71
- className="flex-1 flex items-center justify-end gap-2 py-2.5 px-3 rounded-lg bg-muted/5 hover:bg-muted/10 no-underline transition-colors group text-right"
72
- >
73
- <div className="min-w-0">
74
- <span className="block text-[10px] font-sans font-bold uppercase tracking-widest text-muted mb-0.5">{t('next')}</span>
75
- <span className="block text-sm text-foreground/80 group-hover:text-foreground truncate transition-colors">{nextChapter.title}</span>
76
- </div>
77
- <svg className="w-4 h-4 flex-shrink-0 text-muted group-hover:text-accent transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
78
- <path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
79
- </svg>
80
- </Link>
81
- ) : (
82
- <div className="flex-1" />
83
- )}
53
+ <div className="mb-3">
54
+ <PrevNextNav
55
+ prev={prevChapter ? { href: getBookChapterUrl(bookSlug, prevChapter.id), title: prevChapter.title } : null}
56
+ next={nextChapter ? { href: getBookChapterUrl(bookSlug, nextChapter.id), title: nextChapter.title } : null}
57
+ />
84
58
  </div>
85
59
 
86
60
  {/* Toggle to expand full chapter list */}
@@ -125,7 +99,7 @@ export default function BookMobileNav({ bookSlug, bookTitle, toc, chapters, curr
125
99
  </div>
126
100
  ) : (
127
101
  <Link
128
- href={`/books/${bookSlug}/${ch.id}`}
102
+ href={getBookChapterUrl(bookSlug, ch.id)}
129
103
  className={`block py-1.5 px-2 rounded-lg text-sm no-underline hover:bg-muted/5 transition-colors ${
130
104
  isPast ? 'text-foreground/70 hover:text-foreground' : 'text-muted hover:text-foreground'
131
105
  }`}