@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
@@ -6,6 +6,8 @@ import CoverImage from '@/components/CoverImage';
6
6
  import MarkdownRenderer from '@/components/MarkdownRenderer';
7
7
  import Link from 'next/link';
8
8
  import { t, resolveLocale } from '@/lib/i18n';
9
+ import { buildBookJsonLd, serializeJsonLd } from '@/lib/json-ld';
10
+ import { getBookUrl } from '@/lib/urls';
9
11
 
10
12
  export async function generateStaticParams() {
11
13
  const books = getAllBooks();
@@ -58,8 +60,19 @@ export default async function BookLandingPage({ params }: { params: Promise<{ sl
58
60
 
59
61
  const firstChapter = book.chapters.length > 0 ? book.chapters[0] : null;
60
62
 
63
+ const siteUrl = siteConfig.baseUrl.replace(/\/+$/, '');
64
+ const jsonLd = buildBookJsonLd({
65
+ book,
66
+ bookUrl: `${siteUrl}${getBookUrl(slug)}`,
67
+ siteTitle: resolveLocale(siteConfig.title),
68
+ siteUrl,
69
+ defaultOgImage: siteConfig.ogImage,
70
+ });
71
+
61
72
  return (
62
- <div className="layout-main">
73
+ <>
74
+ <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: serializeJsonLd(jsonLd) }} />
75
+ <div className="layout-main">
63
76
  <header className="mb-16">
64
77
  {/* Cover image */}
65
78
  {book.coverImage && (
@@ -170,6 +183,7 @@ export default async function BookLandingPage({ params }: { params: Promise<{ sl
170
183
  <MarkdownRenderer content={book.content} slug={`books/${book.slug}`} />
171
184
  </section>
172
185
  )}
173
- </div>
186
+ </div>
187
+ </>
174
188
  );
175
189
  }
@@ -1,15 +1,19 @@
1
1
  import { getAllBooks } from '@/lib/markdown';
2
+ import { getBookUrl } from '@/lib/urls';
2
3
  import Link from 'next/link';
3
4
  import { siteConfig } from '../../../site.config';
4
5
  import { Metadata } from 'next';
5
6
  import CoverImage from '@/components/CoverImage';
6
- import { t, resolveLocale } from '@/lib/i18n';
7
+ import { t, resolveLocale, tWith } from '@/lib/i18n';
7
8
  import PageHeader from '@/components/PageHeader';
8
9
 
9
- export const metadata: Metadata = {
10
- title: `${t('books')} | ${resolveLocale(siteConfig.title)}`,
11
- description: 'Structured long-form books and guides.',
12
- };
10
+ export async function generateMetadata(): Promise<Metadata> {
11
+ const books = getAllBooks();
12
+ return {
13
+ title: `${t('books')} | ${resolveLocale(siteConfig.title)}`,
14
+ description: books.length === 1 ? t('books_subtitle_one') : tWith('books_subtitle', { count: books.length }),
15
+ };
16
+ }
13
17
 
14
18
  export default function BooksPage() {
15
19
  const books = getAllBooks();
@@ -18,15 +22,15 @@ export default function BooksPage() {
18
22
  <div className="layout-main">
19
23
  <PageHeader
20
24
  titleKey="books"
21
- subtitleKey="series_subtitle"
22
- subtitleOneKey="series_subtitle_one"
25
+ subtitleKey="books_subtitle"
26
+ subtitleOneKey="books_subtitle_one"
23
27
  count={books.length}
24
28
  subtitleParams={{ count: books.length }}
25
29
  />
26
30
 
27
31
  <div className="grid grid-cols-1 md:grid-cols-2 gap-8">
28
32
  {books.map(book => (
29
- <Link key={book.slug} href={`/books/${book.slug}`} className="group block no-underline">
33
+ <Link key={book.slug} href={getBookUrl(book.slug)} className="group block no-underline">
30
34
  <div className="card-base h-full group flex flex-col p-0 overflow-hidden">
31
35
  <div className="relative h-48 w-full overflow-hidden bg-muted/10">
32
36
  <CoverImage
@@ -0,0 +1,62 @@
1
+ import { siteConfig } from '../../../site.config';
2
+ import { resolveLocale } from '@/lib/i18n';
3
+ import { getFeedItems } from '@/lib/feed-utils';
4
+
5
+ export const dynamic = 'force-static';
6
+
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
+ 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
+ });
62
+ }
@@ -1,44 +1,69 @@
1
- import { getAllPosts } from '@/lib/markdown';
2
1
  import { siteConfig } from '../../../site.config';
3
2
  import { resolveLocale } from '@/lib/i18n';
3
+ import { getFeedItems } from '@/lib/feed-utils';
4
4
 
5
5
  export const dynamic = 'force-static';
6
6
 
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
+
7
13
  export async function GET() {
8
- const posts = getAllPosts();
9
- const baseUrl = siteConfig.baseUrl;
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
+ : '';
10
29
 
11
- const rssItemsXml = posts
12
- .map((post) => {
13
- const url = `${baseUrl}/posts/${post.slug}`;
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
+ : '';
14
38
  return `
15
39
  <item>
16
- <title><![CDATA[${post.title}]]></title>
17
- <link>${url}</link>
18
- <guid>${url}</guid>
19
- <pubDate>${new Date(post.date).toUTCString()}</pubDate>
20
- <description><![CDATA[${post.excerpt}]]></description>
21
- ${post.tags ? post.tags.map(tag => `<category>${tag}</category>`).join('') : ''}
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('')}
22
46
  </item>`;
23
47
  })
24
48
  .join('');
25
49
 
26
50
  const rssXml = `<?xml version="1.0" encoding="UTF-8" ?>
27
- <rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
51
+ <rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/"${contentNs}>
28
52
  <channel>
29
- <title><![CDATA[${resolveLocale(siteConfig.title)}]]></title>
30
- <link>${baseUrl}</link>
31
- <description><![CDATA[${resolveLocale(siteConfig.description)}]]></description>
32
- <language>en</language>
33
- <lastBuildDate>${new Date().toUTCString()}</lastBuildDate>
34
- <atom:link href="${baseUrl}/feed.xml" rel="self" type="application/rss+xml" />
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}
35
59
  ${rssItemsXml}
36
60
  </channel>
37
61
  </rss>`;
38
62
 
39
63
  return new Response(rssXml, {
40
64
  headers: {
41
- 'Content-Type': 'application/xml',
65
+ 'Content-Type': 'application/rss+xml; charset=utf-8',
66
+ 'Cache-Control': 'public, max-age=3600',
42
67
  },
43
68
  });
44
69
  }
@@ -4,6 +4,7 @@ import { Metadata } from 'next';
4
4
  import { notFound } from 'next/navigation';
5
5
  import { t, resolveLocale } from '@/lib/i18n';
6
6
  import FlowCalendarSidebar from '@/components/FlowCalendarSidebar';
7
+ import Tag from '@/components/Tag';
7
8
  import MarkdownRenderer from '@/components/MarkdownRenderer';
8
9
  import Backlinks from '@/components/Backlinks';
9
10
  import ShareBar from '@/components/ShareBar';
@@ -85,11 +86,18 @@ export default async function FlowPage({ params }: { params: Promise<{ year: str
85
86
  {/* Header */}
86
87
  <header className="mb-8">
87
88
  <time className="text-base font-mono text-accent" data-pagefind-meta="date[content]">{flow.date}</time>
89
+ {flow.tags.length > 0 && (
90
+ <div className="mt-3 flex flex-wrap gap-2">
91
+ {flow.tags.map(tag => (
92
+ <Tag key={tag} tag={tag} variant="compact" />
93
+ ))}
94
+ </div>
95
+ )}
88
96
  </header>
89
97
 
90
98
  {/* Content */}
91
99
  <div className="prose prose-lg dark:prose-invert max-w-none">
92
- <MarkdownRenderer content={flow.content} slugRegistry={slugRegistry} />
100
+ <MarkdownRenderer content={flow.content} slug={`flows/${year}/${month}/${day}`} slugRegistry={slugRegistry} />
93
101
  </div>
94
102
 
95
103
  <Backlinks backlinks={backlinks} />
@@ -84,7 +84,7 @@ export default async function FlowsYearPage({ params }: { params: Promise<{ year
84
84
  flows={flows}
85
85
  entryDates={entryDates}
86
86
  tags={tags}
87
- currentDate={`${year}-01-01`}
87
+ currentDate={flows[0]?.date}
88
88
  breadcrumb={breadcrumb}
89
89
  />
90
90
  </div>
@@ -44,12 +44,14 @@ export default async function FlowsPaginatedPage({ params }: { params: Promise<{
44
44
  const start = (page - 1) * PAGE_SIZE;
45
45
  const end = start + PAGE_SIZE;
46
46
  const flows = allFlows.slice(start, end);
47
+ const allFlowItems = allFlows.map(({ slug, date, title, excerpt, tags }) => ({ slug, date, title, excerpt, tags }));
47
48
 
48
49
  return (
49
50
  <div className="layout-main">
50
51
  <FlowHubTabs subtitle={tWith('page_of_total', { page, total: totalPages })} />
51
52
  <FlowContent
52
53
  flows={flows}
54
+ allFlows={allFlowItems}
53
55
  entryDates={entryDates}
54
56
  tags={tags}
55
57
  pagination={{ currentPage: page, totalPages, basePath: '/flows' }}
@@ -20,12 +20,16 @@ export default function FlowsPage() {
20
20
  const flows = allFlows.slice(0, PAGE_SIZE);
21
21
  const entryDates = allFlows.map(f => f.date);
22
22
  const tags = getFlowTags();
23
+ const allFlowItems = totalPages > 1
24
+ ? allFlows.map(({ slug, date, title, excerpt, tags }) => ({ slug, date, title, excerpt, tags }))
25
+ : undefined;
23
26
 
24
27
  return (
25
28
  <div className="layout-main">
26
29
  <FlowHubTabs subtitle={tWith('flow_subtitle', { count: allFlows.length })} />
27
30
  <FlowContent
28
31
  flows={flows}
32
+ allFlows={allFlowItems}
29
33
  entryDates={entryDates}
30
34
  tags={tags}
31
35
  pagination={totalPages > 1 ? { currentPage: 1, totalPages, basePath: '/flows' } : undefined}
@@ -1,7 +1,12 @@
1
1
  @import "tailwindcss";
2
- @import "katex/dist/katex.min.css";
3
2
  @plugin "@tailwindcss/typography";
4
3
 
4
+ html {
5
+ scroll-behavior: smooth;
6
+ scroll-padding-top: 4rem; /* offset for fixed navbar (h-16 = 64px) */
7
+ overflow-x: hidden;
8
+ }
9
+
5
10
  :root {
6
11
  /* Warm/Paper-like background for "Elegant" feel */
7
12
  --background: #fafaf9; /* Stone 50 */
@@ -14,7 +19,7 @@
14
19
  }
15
20
 
16
21
  .dark {
17
- --background: #1c1917; /* Stone 900 */
22
+ --background: #292524; /* Stone 800 */
18
23
  --foreground: #f5f5f4; /* Stone 100 */
19
24
  --heading: #fafaf9; /* Stone 50 */
20
25
  --accent: #34d399; /* Emerald 400 */
@@ -436,6 +441,7 @@ pre[class*="language-"] {
436
441
  body {
437
442
  background-color: var(--background);
438
443
  color: var(--foreground);
444
+ overflow-x: hidden;
439
445
  /* Font smoothing for elegance */
440
446
  -webkit-font-smoothing: antialiased;
441
447
  -moz-osx-font-smoothing: grayscale;
@@ -444,4 +450,4 @@ body {
444
450
  /* Ensure anchor links are not hidden behind the fixed navbar */
445
451
  [id] {
446
452
  scroll-margin-top: 5rem;
447
- }
453
+ }
@@ -10,6 +10,7 @@ import { LanguageProvider } from "@/components/LanguageProvider";
10
10
  import { getAllSeries, getAllBooks, getSeriesData } from "@/lib/markdown";
11
11
  import { resolveLocale } from "@/lib/i18n";
12
12
  import "./globals.css";
13
+ import "katex/dist/katex.min.css";
13
14
 
14
15
  const inter = localFont({
15
16
  src: [
@@ -54,13 +55,33 @@ const siteTwitterHandle = (() => {
54
55
  return m ? `@${m[1]}` : undefined;
55
56
  })();
56
57
 
58
+ // Build icon metadata with explicit MIME type so browsers correctly handle
59
+ // all formats. Without this, .ico files served via the metadata API lack
60
+ // type="image/x-icon" and may be ignored in favour of app/favicon.ico.
61
+ const faviconSrc = siteConfig.logo?.favicon || "/icon.svg";
62
+ const ext = faviconSrc.split('.').pop()?.toLowerCase();
63
+ const faviconMeta =
64
+ ext === 'ico' ? [{ url: faviconSrc, type: 'image/x-icon', sizes: 'any' }] :
65
+ ext === 'png' ? [{ url: faviconSrc, type: 'image/png' }] :
66
+ ext === 'svg' ? [{ url: faviconSrc, type: 'image/svg+xml' }] :
67
+ faviconSrc;
68
+
69
+ const feedAlternates = (() => {
70
+ const { format } = siteConfig.feed;
71
+ const types: Record<string, string> = {};
72
+ if (format === 'rss' || format === 'both') types['application/rss+xml'] = '/feed.xml';
73
+ if (format === 'atom' || format === 'both') types['application/atom+xml'] = '/feed.atom';
74
+ return Object.keys(types).length > 0 ? { types } : undefined;
75
+ })();
76
+
57
77
  export const metadata: Metadata = {
58
78
  metadataBase: new URL(siteConfig.baseUrl),
59
79
  title: resolveLocale(siteConfig.title),
60
80
  description: resolveLocale(siteConfig.description),
61
81
  icons: {
62
- icon: "/icon.svg",
82
+ icon: faviconMeta,
63
83
  },
84
+ ...(feedAlternates && { alternates: feedAlternates }),
64
85
  openGraph: {
65
86
  siteName: resolveLocale(siteConfig.title),
66
87
  locale: siteConfig.i18n.defaultLocale,
@@ -74,7 +74,7 @@ export default async function NotePage({ params }: { params: Promise<{ slug: str
74
74
  breadcrumb={breadcrumb}
75
75
  />
76
76
  )}
77
- <article className="min-w-0 max-w-3xl mx-auto">
77
+ <article className="min-w-0 w-full max-w-3xl mx-auto overflow-x-hidden">
78
78
  <header className="mb-8 border-b border-muted/10 pb-8">
79
79
  {note.draft && (
80
80
  <div className="mb-4">
@@ -98,7 +98,7 @@ export default async function NotePage({ params }: { params: Promise<{ slug: str
98
98
  )}
99
99
  </header>
100
100
 
101
- <MarkdownRenderer content={note.content} slug={note.slug} slugRegistry={slugRegistry} />
101
+ <MarkdownRenderer content={note.content} slug={`notes/${note.slug}`} slugRegistry={slugRegistry} />
102
102
 
103
103
  {/* Prev/Next navigation */}
104
104
  <nav aria-label="Note navigation" className="mt-12 pt-12 border-t border-muted/20 grid grid-cols-2 gap-4">
@@ -126,4 +126,3 @@ export default async function NotePage({ params }: { params: Promise<{ slug: str
126
126
  </div>
127
127
  );
128
128
  }
129
-
package/src/app/page.tsx CHANGED
@@ -1,4 +1,5 @@
1
- import { getAllPosts, getFeaturedSeries, getSeriesData, getFeaturedPosts, getFeaturedBooks, getRecentFlows } from '@/lib/markdown';
1
+ import type { ReactNode } from 'react';
2
+ import { getAllPosts, getAllFlows, getAllSeries, getFeaturedSeries, getSeriesData, getFeaturedPosts, getAllBooks, getFeaturedBooks, getRecentFlows } from '@/lib/markdown';
2
3
  import { siteConfig } from '../../site.config';
3
4
  import Hero from '@/components/Hero';
4
5
  import CuratedSeriesSection, { SeriesItem } from '@/components/CuratedSeriesSection';
@@ -7,7 +8,8 @@ import SelectedBooksSection, { BookItem } from '@/components/SelectedBooksSectio
7
8
  import LatestWritingSection from '@/components/LatestWritingSection';
8
9
  import RecentNotesSection, { RecentNoteItem } from '@/components/RecentNotesSection';
9
10
  import { Metadata } from 'next';
10
- import { resolveLocale } from '@/lib/i18n';
11
+ import { t, resolveLocale } from '@/lib/i18n';
12
+ import { buildWebsiteJsonLd, serializeJsonLd } from '@/lib/json-ld';
11
13
 
12
14
  export const metadata: Metadata = {
13
15
  title: resolveLocale(siteConfig.title),
@@ -21,7 +23,7 @@ export const metadata: Metadata = {
21
23
  images: [{ url: siteConfig.ogImage, width: 1200, height: 630 }],
22
24
  },
23
25
  twitter: {
24
- card: 'summary',
26
+ card: 'summary_large_image',
25
27
  title: resolveLocale(siteConfig.title),
26
28
  description: resolveLocale(siteConfig.description),
27
29
  },
@@ -70,7 +72,7 @@ export default function Home() {
70
72
  return {
71
73
  name,
72
74
  title: seriesData?.title || name,
73
- excerpt: seriesData?.excerpt || "A growing collection of related thoughts.",
75
+ excerpt: seriesData?.excerpt || t('series_default_excerpt'),
74
76
  coverImage: seriesData?.coverImage,
75
77
  url: `/series/${slug}`,
76
78
  postCount: seriesPosts.length,
@@ -103,14 +105,25 @@ export default function Home() {
103
105
  ? featuredPosts.map(p => ({
104
106
  slug: p.slug,
105
107
  title: p.title,
108
+ subtitle: p.subtitle,
106
109
  excerpt: p.excerpt,
107
110
  date: p.date,
108
111
  category: p.category,
109
112
  readingTime: p.readingTime,
110
113
  coverImage: p.coverImage,
114
+ series: p.series,
115
+ pinned: p.pinned,
111
116
  }))
112
117
  : [];
113
118
 
119
+ // Stats for hero navigation chips
120
+ const heroPostCount = has('hero') && features?.posts?.enabled !== false
121
+ ? (needsPosts ? allPosts : getAllPosts()).length
122
+ : undefined;
123
+ const heroSeriesCount = has('hero') && features?.series?.enabled !== false ? Object.keys(getAllSeries()).length : undefined;
124
+ const heroBookCount = has('hero') && features?.books?.enabled !== false ? getAllBooks().length : undefined;
125
+ const heroFlowCount = has('hero') && features?.flow?.enabled !== false ? getAllFlows().length : undefined;
126
+
114
127
  const renderSection = (section: HomepageSection) => {
115
128
  switch (section.id) {
116
129
  case 'featured-series':
@@ -130,7 +143,6 @@ export default function Home() {
130
143
  key="featured-books"
131
144
  books={bookItems}
132
145
  maxItems={section.maxItems ?? 4}
133
- scrollThreshold={section.scrollThreshold ?? 2}
134
146
  />
135
147
  );
136
148
  case 'featured-posts':
@@ -140,7 +152,6 @@ export default function Home() {
140
152
  key="featured-posts"
141
153
  allFeatured={featuredItems}
142
154
  maxItems={section.maxItems ?? 4}
143
- scrollThreshold={section.scrollThreshold ?? 1}
144
155
  />
145
156
  );
146
157
  case 'latest-posts':
@@ -154,19 +165,86 @@ export default function Home() {
154
165
  }
155
166
  };
156
167
 
168
+ // Build content sections, pairing latest-posts + recent-flows into a two-column layout
169
+ const sectionsForContent = sections.filter(s => s.id !== 'hero');
170
+ const latestIdx = sectionsForContent.findIndex(s => s.id === 'latest-posts');
171
+ const flowsIdx = sectionsForContent.findIndex(s => s.id === 'recent-flows');
172
+ const pairLatestFlows = latestIdx >= 0 && flowsIdx >= 0;
173
+
174
+ // Show a divider after the two-column section when series follows it
175
+ const divideBeforeSeries = pairLatestFlows
176
+ && has('featured-series')
177
+ && features?.series?.enabled !== false
178
+ && seriesItems.length > 0;
179
+
180
+ const renderList: ReactNode[] = [];
181
+ const skippedIds = new Set<string>();
182
+
183
+ for (const section of sectionsForContent) {
184
+ if (skippedIds.has(section.id)) continue;
185
+
186
+ if (pairLatestFlows && (section.id === 'latest-posts' || section.id === 'recent-flows')) {
187
+ skippedIds.add(section.id === 'latest-posts' ? 'recent-flows' : 'latest-posts');
188
+ const showLatest = features?.posts?.enabled !== false;
189
+ const showFlows = features?.flow?.enabled !== false && recentNoteItems.length > 0;
190
+ if (showLatest || showFlows) {
191
+ renderList.push(
192
+ <div key="latest-flows-combined" className={`grid grid-cols-1 lg:grid-cols-12 gap-12 lg:gap-16 ${divideBeforeSeries ? 'mb-16' : 'mb-24'}`}>
193
+ {showLatest && (
194
+ <div className="lg:col-span-7">
195
+ <LatestWritingSection posts={posts} totalCount={allPosts.length} />
196
+ </div>
197
+ )}
198
+ {showFlows && (
199
+ <div className="lg:col-span-5">
200
+ <RecentNotesSection notes={recentNoteItems} />
201
+ </div>
202
+ )}
203
+ </div>
204
+ );
205
+ if (divideBeforeSeries) {
206
+ renderList.push(
207
+ <div key="series-divider" className="border-t border-muted/10 mb-16" />
208
+ );
209
+ }
210
+ }
211
+ } else {
212
+ renderList.push(renderSection(section));
213
+ }
214
+ }
215
+
216
+ const websiteJsonLd = buildWebsiteJsonLd({
217
+ siteTitle: resolveLocale(siteConfig.title),
218
+ siteUrl: siteConfig.baseUrl,
219
+ description: resolveLocale(siteConfig.description),
220
+ });
221
+
157
222
  return (
223
+ <>
224
+ <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: serializeJsonLd(websiteJsonLd) }} />
158
225
  <div>
159
226
  {has('hero') && (
160
227
  <Hero
161
228
  tagline={siteConfig.hero.tagline}
162
229
  title={siteConfig.hero.title}
163
230
  subtitle={siteConfig.hero.subtitle}
231
+ postCount={heroPostCount}
232
+ seriesCount={heroSeriesCount}
233
+ bookCount={heroBookCount}
234
+ flowCount={heroFlowCount}
235
+ featureNames={{
236
+ flow: features?.flow?.name,
237
+ posts: features?.posts?.name,
238
+ series: features?.series?.name,
239
+ books: features?.books?.name,
240
+ }}
164
241
  />
165
242
  )}
166
243
 
167
- <div className="layout-main pt-0 md:pt-0">
168
- {sections.filter(s => s.id !== 'hero').map(renderSection)}
244
+ <div className="layout-main pt-0 md:pt-0 [&>*:last-child]:mb-0">
245
+ {renderList}
169
246
  </div>
170
247
  </div>
248
+ </>
171
249
  );
172
250
  }
@@ -5,6 +5,8 @@ import SimpleLayout from '@/layouts/SimpleLayout';
5
5
  import { Metadata } from 'next';
6
6
  import { siteConfig } from '../../../../site.config';
7
7
  import { resolveLocale } from '@/lib/i18n';
8
+ import { getPostsBasePath, getPostUrl } from '@/lib/urls';
9
+ import { buildPostJsonLd, serializeJsonLd, resolveImageUrl } from '@/lib/json-ld';
8
10
 
9
11
  function safeDecodeParam(param: string): string {
10
12
  try {
@@ -29,9 +31,20 @@ function resolvePostFromParam(rawSlug: string) {
29
31
  * This ensures fast page loads and SEO optimization.
30
32
  */
31
33
  export async function generateStaticParams() {
34
+ if (getPostsBasePath() !== 'posts') return [{ slug: '_' }]; // Route disabled; custom path handles this
32
35
  const posts = getAllPosts();
33
36
  if (posts.length === 0) return [{ slug: '_' }];
34
- return posts.map((post) => ({ slug: post.slug }));
37
+ // Work around Next dev static-param checks for percent-encoded Unicode paths
38
+ // under `output: "export"` by including encoded variants only in development.
39
+ // Production export keeps raw segment values.
40
+ const slugs = new Set<string>();
41
+ for (const post of posts) {
42
+ slugs.add(post.slug);
43
+ if (process.env.NODE_ENV !== 'production') {
44
+ slugs.add(encodeURIComponent(post.slug));
45
+ }
46
+ }
47
+ return Array.from(slugs).map((slug) => ({ slug }));
35
48
  }
36
49
 
37
50
  export const dynamicParams = false;
@@ -46,9 +59,8 @@ export async function generateMetadata({ params }: { params: Promise<{ slug: str
46
59
  };
47
60
  }
48
61
 
49
- const ogImage = post.coverImage && !post.coverImage.startsWith('text:') && !post.coverImage.startsWith('./')
50
- ? post.coverImage
51
- : siteConfig.ogImage;
62
+ const siteUrl = siteConfig.baseUrl.replace(/\/+$/, '');
63
+ const ogImage = resolveImageUrl(post.coverImage, siteConfig.ogImage, siteUrl);
52
64
 
53
65
  return {
54
66
  title: `${post.title} | ${resolveLocale(siteConfig.title)}`,
@@ -94,8 +106,18 @@ export default async function PostPage({
94
106
  // Determine layout based on frontmatter
95
107
  const layout = post.layout || 'post';
96
108
 
109
+ const siteUrl = siteConfig.baseUrl.replace(/\/+$/, '');
110
+ const jsonLd = buildPostJsonLd({
111
+ post,
112
+ postUrl: `${siteUrl}${getPostUrl(post)}`,
113
+ siteTitle: resolveLocale(siteConfig.title),
114
+ siteUrl,
115
+ defaultOgImage: siteConfig.ogImage,
116
+ });
117
+ const jsonLdScript = <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: serializeJsonLd(jsonLd) }} />;
118
+
97
119
  if (layout === 'simple') {
98
- return <SimpleLayout post={post} />;
120
+ return <>{jsonLdScript}<SimpleLayout post={post} /></>;
99
121
  }
100
122
 
101
123
  const relatedPosts = getRelatedPosts(slug);
@@ -111,6 +133,10 @@ export default async function PostPage({
111
133
  seriesTitle = seriesData?.title;
112
134
  }
113
135
 
114
- // Default to standard post layout
115
- return <PostLayout post={post} relatedPosts={relatedPosts} seriesPosts={seriesPosts} seriesTitle={seriesTitle} prevPost={prev} nextPost={next} backlinks={backlinks} slugRegistry={slugRegistry} />;
136
+ return (
137
+ <>
138
+ {jsonLdScript}
139
+ <PostLayout post={post} relatedPosts={relatedPosts} seriesPosts={seriesPosts} seriesTitle={seriesTitle} prevPost={prev} nextPost={next} backlinks={backlinks} slugRegistry={slugRegistry} />
140
+ </>
141
+ );
116
142
  }