@hutusi/amytis 1.7.0 → 1.9.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 (83) hide show
  1. package/.github/workflows/ci.yml +1 -1
  2. package/CHANGELOG.md +63 -0
  3. package/CLAUDE.md +9 -18
  4. package/GEMINI.md +6 -0
  5. package/README.md +44 -0
  6. package/TODO.md +15 -3
  7. package/bun.lock +5 -3
  8. package/content/about.mdx +64 -10
  9. package/content/about.zh.mdx +66 -9
  10. package/content/books/sample-book/index.mdx +3 -3
  11. package/content/flows/2026/02/05.md +0 -1
  12. package/content/flows/2026/02/10.mdx +2 -1
  13. package/content/flows/2026/02/15.md +2 -1
  14. package/content/flows/2026/02/18.mdx +2 -1
  15. package/content/flows/2026/02/20.md +0 -1
  16. package/content/notes/algorithms-and-data-structures.mdx +51 -0
  17. package/content/notes/digital-garden-philosophy.mdx +36 -0
  18. package/content/notes/react-server-components.mdx +49 -0
  19. package/content/notes/tailwind-v4.mdx +45 -0
  20. package/content/notes/zettelkasten-method.mdx +33 -0
  21. package/content/series/digital-garden/01-philosophy.mdx +25 -12
  22. package/docs/ARCHITECTURE.md +9 -1
  23. package/docs/CONTRIBUTING.md +26 -0
  24. package/docs/DIGITAL_GARDEN.md +72 -0
  25. package/imports/README.md +45 -0
  26. package/package.json +12 -5
  27. package/scripts/generate-knowledge-graph.ts +162 -0
  28. package/scripts/import-book.ts +176 -0
  29. package/scripts/new-flow-from-chat.ts +238 -0
  30. package/scripts/new-flow.ts +0 -5
  31. package/scripts/new-note.ts +53 -0
  32. package/scripts/sync-book-chapters.ts +210 -0
  33. package/site.config.ts +30 -7
  34. package/src/app/authors/[author]/page.tsx +3 -1
  35. package/src/app/books/[slug]/[chapter]/page.tsx +2 -1
  36. package/src/app/books/[slug]/page.tsx +6 -5
  37. package/src/app/flows/[year]/[month]/[day]/page.tsx +35 -29
  38. package/src/app/flows/[year]/[month]/page.tsx +18 -13
  39. package/src/app/flows/[year]/page.tsx +25 -15
  40. package/src/app/flows/page/[page]/page.tsx +5 -9
  41. package/src/app/flows/page.tsx +5 -8
  42. package/src/app/globals.css +41 -0
  43. package/src/app/graph/page.tsx +21 -0
  44. package/src/app/layout.tsx +4 -2
  45. package/src/app/notes/[slug]/page.tsx +129 -0
  46. package/src/app/notes/page/[page]/page.tsx +60 -0
  47. package/src/app/notes/page.tsx +33 -0
  48. package/src/app/page/[page]/page.tsx +1 -0
  49. package/src/app/page.tsx +4 -5
  50. package/src/app/posts/[slug]/page.tsx +5 -2
  51. package/src/app/posts/page/[page]/page.tsx +4 -1
  52. package/src/app/search.json/route.ts +17 -3
  53. package/src/app/series/[slug]/page/[page]/page.tsx +1 -0
  54. package/src/app/series/[slug]/page.tsx +3 -3
  55. package/src/app/sitemap.ts +1 -1
  56. package/src/app/tags/[tag]/page.tsx +3 -3
  57. package/src/components/Backlinks.tsx +39 -0
  58. package/src/components/BookMobileNav.tsx +11 -11
  59. package/src/components/BookSidebar.tsx +17 -25
  60. package/src/components/BrowserDetectionBanner.tsx +96 -0
  61. package/src/components/FeaturedStoriesSection.tsx +1 -1
  62. package/src/components/FlowCalendarSidebar.tsx +4 -2
  63. package/src/components/FlowContent.tsx +4 -3
  64. package/src/components/FlowHubTabs.tsx +50 -0
  65. package/src/components/FlowTimelineEntry.tsx +7 -9
  66. package/src/components/KnowledgeGraph.tsx +324 -0
  67. package/src/components/LanguageProvider.tsx +14 -5
  68. package/src/components/MarkdownRenderer.tsx +13 -2
  69. package/src/components/Navbar.tsx +237 -10
  70. package/src/components/NoteContent.tsx +123 -0
  71. package/src/components/NoteSidebar.tsx +132 -0
  72. package/src/components/RecentNotesSection.tsx +6 -11
  73. package/src/components/Search.tsx +7 -3
  74. package/src/components/TagContentTabs.tsx +0 -1
  75. package/src/i18n/translations.ts +43 -17
  76. package/src/layouts/BookLayout.tsx +3 -3
  77. package/src/layouts/PostLayout.tsx +8 -3
  78. package/src/lib/i18n.ts +83 -6
  79. package/src/lib/markdown.ts +306 -19
  80. package/src/lib/remark-wikilinks.ts +59 -0
  81. package/src/lib/search-utils.ts +2 -1
  82. package/tests/unit/static-params.test.ts +238 -0
  83. package/content/series/digital-garden/01-philosophy/index.mdx +0 -23
@@ -0,0 +1,210 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import matter from 'gray-matter';
4
+
5
+ // Usage:
6
+ // bun run sync-book # sync all books
7
+ // bun run sync-book <book-slug> # sync one book
8
+ // bun run sync-book <book-slug> --update-titles # also refresh titles from files
9
+ //
10
+ // Note: when the file is written, matter.stringify re-serializes all frontmatter fields
11
+ // (e.g. drops explicit quotes on simple strings, normalises indentation). Expect a one-time
12
+ // cosmetic formatting diff the first time you sync a pre-existing book file.
13
+
14
+ const args = process.argv.slice(2);
15
+ const targetSlug = args.find(a => !a.startsWith('--'));
16
+ const updateTitles = args.includes('--update-titles');
17
+
18
+ const booksDir = path.join(process.cwd(), 'content', 'books');
19
+
20
+ // ── Types ─────────────────────────────────────────────────────────────────
21
+
22
+ type ChapterRef = { title: string; id: string };
23
+ type PartGroup = { part: string; chapters: ChapterRef[] };
24
+ type TocItem = ChapterRef | PartGroup;
25
+
26
+ // ── Helpers ───────────────────────────────────────────────────────────────
27
+
28
+ function titleFromId(id: string): string {
29
+ return id
30
+ .split(/[-_]/)
31
+ .map(w => w.charAt(0).toUpperCase() + w.slice(1))
32
+ .join(' ');
33
+ }
34
+
35
+ function readChapterTitle(bookDir: string, id: string): string {
36
+ const candidates = [
37
+ path.join(bookDir, `${id}.mdx`),
38
+ path.join(bookDir, `${id}.md`),
39
+ path.join(bookDir, id, 'index.mdx'),
40
+ path.join(bookDir, id, 'index.md'),
41
+ ];
42
+ for (const p of candidates) {
43
+ if (fs.existsSync(p)) {
44
+ const { data } = matter(fs.readFileSync(p, 'utf8'));
45
+ return (data.title as string | undefined) || titleFromId(id);
46
+ }
47
+ }
48
+ return titleFromId(id);
49
+ }
50
+
51
+ /** Scan the book directory and return ids of all discovered chapters, sorted by name. */
52
+ function discoverIds(bookDir: string): string[] {
53
+ const entries = fs.readdirSync(bookDir, { withFileTypes: true });
54
+ const ids: string[] = [];
55
+
56
+ for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
57
+ // Skip the book index itself
58
+ if (entry.name === 'index.mdx' || entry.name === 'index.md') continue;
59
+
60
+ if (entry.isFile() && (entry.name.endsWith('.mdx') || entry.name.endsWith('.md'))) {
61
+ ids.push(entry.name.replace(/\.mdx?$/, ''));
62
+ } else if (entry.isDirectory()) {
63
+ // Only treat a folder as a chapter if it contains an index file
64
+ const hasIndex =
65
+ fs.existsSync(path.join(bookDir, entry.name, 'index.mdx')) ||
66
+ fs.existsSync(path.join(bookDir, entry.name, 'index.md'));
67
+ if (hasIndex) ids.push(entry.name);
68
+ }
69
+ }
70
+
71
+ return ids;
72
+ }
73
+
74
+ /** Return a flat list of all ids mentioned in a toc. */
75
+ function flattenToc(toc: TocItem[]): string[] {
76
+ const ids: string[] = [];
77
+ for (const item of toc) {
78
+ if ('part' in item) {
79
+ for (const ch of item.chapters) ids.push(ch.id);
80
+ } else {
81
+ ids.push(item.id);
82
+ }
83
+ }
84
+ return ids;
85
+ }
86
+
87
+ /**
88
+ * Reconcile the existing toc against what is on disk:
89
+ * - Remove entries for files that no longer exist.
90
+ * - Optionally refresh titles from file frontmatter.
91
+ * - Append newly discovered ids as flat entries at the end.
92
+ */
93
+ function reconcile(
94
+ toc: TocItem[],
95
+ bookDir: string,
96
+ discovered: Set<string>,
97
+ ): { updated: TocItem[]; added: string[]; removed: string[] } {
98
+ const added: string[] = [];
99
+ const removed: string[] = [];
100
+ const updated: TocItem[] = [];
101
+
102
+ // Walk existing toc, prune missing entries
103
+ for (const item of toc) {
104
+ if ('part' in item) {
105
+ const kept: ChapterRef[] = [];
106
+ for (const ch of item.chapters) {
107
+ if (discovered.has(ch.id)) {
108
+ kept.push(
109
+ updateTitles
110
+ ? { ...ch, title: readChapterTitle(bookDir, ch.id) }
111
+ : ch,
112
+ );
113
+ } else {
114
+ removed.push(ch.id);
115
+ }
116
+ }
117
+ if (kept.length > 0) updated.push({ part: item.part, chapters: kept });
118
+ } else {
119
+ if (discovered.has(item.id)) {
120
+ updated.push(
121
+ updateTitles
122
+ ? { ...item, title: readChapterTitle(bookDir, item.id) }
123
+ : item,
124
+ );
125
+ } else {
126
+ removed.push(item.id);
127
+ }
128
+ }
129
+ }
130
+
131
+ // Append ids that are on disk but not yet in toc
132
+ const existingIds = new Set(flattenToc(toc));
133
+ const newIds = [...discovered].filter(id => !existingIds.has(id)).sort();
134
+ for (const id of newIds) {
135
+ updated.push({ title: readChapterTitle(bookDir, id), id });
136
+ added.push(id);
137
+ }
138
+
139
+ return { updated, added, removed };
140
+ }
141
+
142
+ // ── Sync one book ─────────────────────────────────────────────────────────
143
+
144
+ function syncBook(bookSlug: string): void {
145
+ const bookDir = path.join(booksDir, bookSlug);
146
+
147
+ const indexMdx = path.join(bookDir, 'index.mdx');
148
+ const indexMd = path.join(bookDir, 'index.md');
149
+ let indexPath = '';
150
+ if (fs.existsSync(indexMdx)) indexPath = indexMdx;
151
+ else if (fs.existsSync(indexMd)) indexPath = indexMd;
152
+ else {
153
+ console.error(` ✗ ${bookSlug}: no index.mdx / index.md found`);
154
+ return;
155
+ }
156
+
157
+ const raw = fs.readFileSync(indexPath, 'utf8');
158
+ const { data, content } = matter(raw);
159
+
160
+ if (data.chapters !== undefined && !Array.isArray(data.chapters)) {
161
+ console.error(` ✗ ${bookSlug}: "chapters" frontmatter must be an array`);
162
+ return;
163
+ }
164
+
165
+ const discovered = new Set(discoverIds(bookDir));
166
+ const existingToc = (data.chapters ?? []) as TocItem[];
167
+
168
+ const { updated, added, removed } = reconcile(existingToc, bookDir, discovered);
169
+
170
+ const titlesChanged = updateTitles && JSON.stringify(updated) !== JSON.stringify(existingToc);
171
+ const changed = added.length > 0 || removed.length > 0 || titlesChanged;
172
+ if (!changed) {
173
+ console.log(` ✓ ${bookSlug}: up to date (${discovered.size} chapter${discovered.size === 1 ? '' : 's'})`);
174
+ return;
175
+ }
176
+
177
+ data.chapters = updated;
178
+ fs.writeFileSync(indexPath, matter.stringify(content, data));
179
+
180
+ console.log(` ✓ ${bookSlug}:`);
181
+ if (added.length > 0) console.log(` + added: ${added.join(', ')}`);
182
+ if (removed.length > 0) console.log(` - removed: ${removed.join(', ')}`);
183
+ if (titlesChanged) console.log(` ↺ titles refreshed from files`);
184
+ }
185
+
186
+ // ── Entry point ───────────────────────────────────────────────────────────
187
+
188
+ if (!fs.existsSync(booksDir)) {
189
+ console.error('No content/books directory found.');
190
+ process.exit(1);
191
+ }
192
+
193
+ console.log('Syncing book chapters…');
194
+
195
+ if (targetSlug) {
196
+ const bookDir = path.join(booksDir, targetSlug);
197
+ if (!fs.existsSync(bookDir)) {
198
+ console.error(`Book "${targetSlug}" not found in ${booksDir}`);
199
+ process.exit(1);
200
+ }
201
+ syncBook(targetSlug);
202
+ } else {
203
+ const entries = fs.readdirSync(booksDir, { withFileTypes: true });
204
+ const books = entries.filter(e => e.isDirectory()).sort((a, b) => a.name.localeCompare(b.name));
205
+ if (books.length === 0) {
206
+ console.log('No books found.');
207
+ } else {
208
+ for (const entry of books) syncBook(entry.name);
209
+ }
210
+ }
package/site.config.ts CHANGED
@@ -1,8 +1,17 @@
1
+ export interface NavChildItem {
2
+ name: string;
3
+ url: string;
4
+ external?: boolean;
5
+ dividerBefore?: boolean; // render a separator line before this item
6
+ }
7
+
1
8
  export interface NavItem {
2
9
  name: string;
3
10
  url: string;
4
11
  weight: number;
12
+ external?: boolean;
5
13
  dropdown?: string[];
14
+ children?: NavChildItem[]; // static sub-links rendered as a dropdown
6
15
  }
7
16
 
8
17
  // Defined up-front so footer.connect can reference these URLs without duplication
@@ -16,7 +25,7 @@ export const siteConfig = {
16
25
 
17
26
  // ── Site identity ─────────────────────────────────────────────────────────
18
27
  title: { en: "Amytis", zh: "Amytis" },
19
- description: { en: "A minimalist digital garden for growing thoughts and sharing knowledge.", zh: "一个极简的数字花园,用于培育思想和分享知识。" },
28
+ description: { en: "Amytis an elegant open-source framework for building your personal digital garden.", zh: "Amytis — 优雅的开源数字花园框架。" },
20
29
  baseUrl: "https://example.com", // Replace with your actual domain
21
30
  ogImage: "/og-image.png", // Default OG/social preview image — place a 1200×630 PNG at public/og-image.png
22
31
  footerText: { en: `© ${new Date().getFullYear()} Amytis. All rights reserved.`, zh: `© ${new Date().getFullYear()} Amytis. 保留所有权利。` },
@@ -34,6 +43,12 @@ export const siteConfig = {
34
43
  { name: "Series", url: "/series", weight: 3, dropdown: ["digital-garden", "markdown-showcase", "ai-nexus-weekly"] },
35
44
  { name: "Books", url: "/books", weight: 4, dropdown: [] },
36
45
  { name: "About", url: "/about", weight: 5 },
46
+ { name: "More", url: "", weight: 6, children: [
47
+ { name: "Archive", url: "/archive" },
48
+ { name: "Tags", url: "/tags" },
49
+ { name: "Links", url: "/links" },
50
+ { name: "Subscribe", url: "/subscribe", dividerBefore: true },
51
+ ]},
37
52
  ] as NavItem[],
38
53
 
39
54
  // ── Footer ────────────────────────────────────────────────────────────────
@@ -79,7 +94,7 @@ export const siteConfig = {
79
94
  features: {
80
95
  posts: {
81
96
  enabled: true,
82
- name: { en: "Posts", zh: "文章" },
97
+ name: { en: "Articles", zh: "文章" },
83
98
  },
84
99
  series: {
85
100
  enabled: true,
@@ -89,7 +104,7 @@ export const siteConfig = {
89
104
  enabled: true,
90
105
  name: { en: "Books", zh: "书籍" },
91
106
  },
92
- flows: {
107
+ flow: {
93
108
  enabled: true,
94
109
  name: { en: "Flow", zh: "随笔" },
95
110
  },
@@ -97,9 +112,9 @@ export const siteConfig = {
97
112
 
98
113
  // ── Homepage ──────────────────────────────────────────────────────────────
99
114
  hero: {
100
- tagline: { en: "Digital Garden", zh: "数字花园" },
101
- title: { en: "Cultivating Digital Knowledge", zh: "培育数字知识" },
102
- subtitle: { en: "A minimalist digital garden for growing thoughts and sharing knowledge.", zh: "一个极简的数字花园,用于培育思想和分享知识。" },
115
+ tagline: { en: "Open Source Digital Garden", zh: "开源数字花园框架" },
116
+ title: { en: "A home for ideas to grow, link, and evolve.", zh: "让想法生长、关联、演化的地方。" },
117
+ subtitle: { en: "An elegant, open-source framework for cultivating personal knowledge — from raw daily flows to refined articles, curated series, and structured books.", zh: "优雅的开源知识培育框架——从每日随笔到精炼文章,从系列合集到结构化书籍,层层深化。" },
103
118
  },
104
119
  homepage: {
105
120
  sections: [
@@ -115,8 +130,9 @@ export const siteConfig = {
115
130
  // ── Content ───────────────────────────────────────────────────────────────
116
131
  pagination: {
117
132
  posts: 5,
118
- series: 1,
133
+ series: 5,
119
134
  flows: 20,
135
+ notes: 20,
120
136
  },
121
137
  posts: {
122
138
  toc: true,
@@ -134,6 +150,13 @@ export const siteConfig = {
134
150
  // ── Appearance ────────────────────────────────────────────────────────────
135
151
  themeColor: 'default', // 'default' | 'blue' | 'rose' | 'amber'
136
152
 
153
+ // ── Browser compatibility warning ─────────────────────────────────────────
154
+ browserCheck: {
155
+ // URL shown in the outdated-browser banner. Set to '' to hide the link
156
+ // (useful for corporate/intranet deployments where IT manages upgrades).
157
+ updateUrl: 'https://browsehappy.com/',
158
+ },
159
+
137
160
  // ── Analytics ─────────────────────────────────────────────────────────────
138
161
  analytics: {
139
162
  provider: 'umami', // 'umami' | 'plausible' | 'google' | null
@@ -12,10 +12,12 @@ import TranslatedText from '@/components/TranslatedText';
12
12
 
13
13
  export async function generateStaticParams() {
14
14
  const authors = getAllAuthors();
15
+ const authorNames = Object.keys(authors);
16
+ if (authorNames.length === 0) return [{ author: '_' }];
15
17
  const params = new Set<string>();
16
18
 
17
19
  // Generate slug-based routes and keep legacy name-based routes for compatibility.
18
- for (const authorName of Object.keys(authors)) {
20
+ for (const authorName of authorNames) {
19
21
  params.add(getAuthorSlug(authorName));
20
22
  params.add(authorName);
21
23
  }
@@ -7,11 +7,12 @@ import { resolveLocale } from '@/lib/i18n';
7
7
 
8
8
  export async function generateStaticParams() {
9
9
  const books = getAllBooks();
10
+ if (books.length === 0) return [{ slug: '_', chapter: '_' }];
10
11
  const params: { slug: string; chapter: string }[] = [];
11
12
 
12
13
  for (const book of books) {
13
14
  for (const ch of book.chapters) {
14
- params.push({ slug: book.slug, chapter: ch.file });
15
+ params.push({ slug: book.slug, chapter: ch.id });
15
16
  }
16
17
  }
17
18
 
@@ -9,6 +9,7 @@ import { t, resolveLocale } from '@/lib/i18n';
9
9
 
10
10
  export async function generateStaticParams() {
11
11
  const books = getAllBooks();
12
+ if (books.length === 0) return [{ slug: '_' }];
12
13
  return books.map(book => ({ slug: book.slug }));
13
14
  }
14
15
 
@@ -104,7 +105,7 @@ export default async function BookLandingPage({ params }: { params: Promise<{ sl
104
105
  {firstChapter && (
105
106
  <div className="mt-8">
106
107
  <Link
107
- href={`/books/${book.slug}/${firstChapter.file}`}
108
+ href={`/books/${book.slug}/${firstChapter.id}`}
108
109
  className="inline-flex items-center gap-2 px-6 py-3 bg-accent text-white rounded-xl font-sans font-medium text-sm hover:bg-accent/90 no-underline transition-colors shadow-lg shadow-accent/20"
109
110
  >
110
111
  {t('start_reading')}
@@ -130,9 +131,9 @@ export default async function BookLandingPage({ params }: { params: Promise<{ sl
130
131
  </h3>
131
132
  <ol className="space-y-2 pl-4 border-l-2 border-muted/10">
132
133
  {item.chapters.map(ch => (
133
- <li key={ch.file}>
134
+ <li key={ch.id}>
134
135
  <Link
135
- href={`/books/${book.slug}/${ch.file}`}
136
+ href={`/books/${book.slug}/${ch.id}`}
136
137
  className="group flex items-center gap-3 py-2 text-foreground/80 hover:text-accent no-underline transition-colors"
137
138
  >
138
139
  <svg className="w-4 h-4 text-muted group-hover:text-accent flex-shrink-0 transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
@@ -148,8 +149,8 @@ export default async function BookLandingPage({ params }: { params: Promise<{ sl
148
149
  } else {
149
150
  return (
150
151
  <Link
151
- key={item.file}
152
- href={`/books/${book.slug}/${item.file}`}
152
+ key={item.id}
153
+ href={`/books/${book.slug}/${item.id}`}
153
154
  className="group flex items-center gap-3 py-2 text-foreground/80 hover:text-accent no-underline transition-colors"
154
155
  >
155
156
  <svg className="w-4 h-4 text-muted group-hover:text-accent flex-shrink-0 transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
@@ -1,15 +1,18 @@
1
- import { getAllFlows, getFlowBySlug, getAdjacentFlows } from '@/lib/markdown';
1
+ import { getAllFlows, getFlowBySlug, getAdjacentFlows, buildSlugRegistry, getBacklinks } from '@/lib/markdown';
2
2
  import { siteConfig } from '../../../../../../site.config';
3
3
  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
7
  import MarkdownRenderer from '@/components/MarkdownRenderer';
8
+ import Backlinks from '@/components/Backlinks';
8
9
  import ShareBar from '@/components/ShareBar';
9
10
  import Link from 'next/link';
10
11
 
11
12
  export function generateStaticParams() {
13
+ if (siteConfig.features?.flow?.enabled === false) return [{ year: '_', month: '_', day: '_' }];
12
14
  const allFlows = getAllFlows();
15
+ if (allFlows.length === 0) return [{ year: '_', month: '_', day: '_' }];
13
16
  return allFlows.map(flow => {
14
17
  const [year, month, day] = flow.slug.split('/');
15
18
  return { year, month, day };
@@ -42,6 +45,7 @@ export async function generateMetadata({ params }: { params: Promise<{ year: str
42
45
  }
43
46
 
44
47
  export default async function FlowPage({ params }: { params: Promise<{ year: string; month: string; day: string }> }) {
48
+ if (siteConfig.features?.flow?.enabled === false) notFound();
45
49
  const { year, month, day } = await params;
46
50
  const slug = `${year}/${month}/${day}`;
47
51
  const flow = getFlowBySlug(slug);
@@ -50,56 +54,59 @@ export default async function FlowPage({ params }: { params: Promise<{ year: str
50
54
  const allFlows = getAllFlows();
51
55
  const entryDates = allFlows.map(f => f.date);
52
56
  const { prev, next } = getAdjacentFlows(flow.slug);
57
+ const slugRegistry = buildSlugRegistry();
58
+ const backlinks = getBacklinks(flow.slug);
53
59
  const flowUrl = `${siteConfig.baseUrl}/flows/${year}/${month}/${day}`;
54
60
 
61
+ const breadcrumb = (
62
+ <nav aria-label="Breadcrumb" className="flex flex-wrap items-center gap-1.5 text-sm text-muted">
63
+ <Link href="/flows" className="hover:text-accent no-underline shrink-0">
64
+ {t('all_flows')}
65
+ </Link>
66
+ <span className="text-muted/40" aria-hidden="true">›</span>
67
+ <Link href={`/flows/${year}`} className="hover:text-accent no-underline shrink-0">
68
+ {year}
69
+ </Link>
70
+ <span className="text-muted/40" aria-hidden="true">›</span>
71
+ <Link href={`/flows/${year}/${month}`} className="hover:text-accent no-underline shrink-0">
72
+ {month}
73
+ </Link>
74
+ <span className="text-muted/40" aria-hidden="true">›</span>
75
+ <span className="text-foreground shrink-0">{day}</span>
76
+ </nav>
77
+ );
78
+
55
79
  return (
56
80
  <div className="layout-main">
57
- {/* Breadcrumb navigation */}
58
- <nav className="flex items-center gap-1.5 text-sm text-muted mb-6">
59
- <Link href="/flows" className="hover:text-accent no-underline">
60
- {t('all_flows')}
61
- </Link>
62
- <span className="text-muted/40">›</span>
63
- <Link href={`/flows/${year}`} className="hover:text-accent no-underline">
64
- {year}
65
- </Link>
66
- <span className="text-muted/40">›</span>
67
- <Link href={`/flows/${year}/${month}`} className="hover:text-accent no-underline">
68
- {month}
69
- </Link>
70
- <span className="text-muted/40">›</span>
71
- <span className="text-foreground">{day}</span>
72
- </nav>
73
-
74
81
  <div className="flex gap-10">
75
- <FlowCalendarSidebar entryDates={entryDates} currentDate={flow.date} />
82
+ <FlowCalendarSidebar entryDates={entryDates} currentDate={flow.date} breadcrumb={breadcrumb} />
76
83
 
77
84
  <article className="flex-1 min-w-0">
78
85
  {/* Header */}
79
86
  <header className="mb-8">
80
- <time className="text-sm font-mono text-accent" data-pagefind-meta="date[content]">{flow.date}</time>
81
- <h1 className="mt-2 text-3xl md:text-4xl font-serif font-bold text-heading">{flow.title}</h1>
87
+ <time className="text-base font-mono text-accent" data-pagefind-meta="date[content]">{flow.date}</time>
82
88
  </header>
83
89
 
84
90
  {/* Content */}
85
91
  <div className="prose prose-lg dark:prose-invert max-w-none">
86
- <MarkdownRenderer content={flow.content} />
92
+ <MarkdownRenderer content={flow.content} slugRegistry={slugRegistry} />
87
93
  </div>
88
94
 
95
+ <Backlinks backlinks={backlinks} />
96
+
89
97
  <ShareBar url={flowUrl} title={flow.title} className="mt-8 mb-2" />
90
98
 
91
99
  {/* Prev/Next navigation */}
92
- <nav className="mt-12 pt-12 border-t border-muted/20 grid grid-cols-2 gap-4">
100
+ <nav aria-label="Post navigation" className="mt-12 pt-12 border-t border-muted/20 grid grid-cols-2 gap-4">
93
101
  {prev ? (
94
102
  <Link
95
103
  href={`/flows/${prev.slug}`}
96
104
  className="group text-left no-underline"
97
105
  >
98
106
  <span className="text-xs text-muted">{t('older')}</span>
99
- <div className="text-sm font-medium text-heading group-hover:text-accent transition-colors truncate">
100
- {prev.title}
107
+ <div className="text-sm font-mono text-heading group-hover:text-accent transition-colors">
108
+ {prev.date}
101
109
  </div>
102
- <span className="text-xs font-mono text-muted">{prev.date}</span>
103
110
  </Link>
104
111
  ) : <div />}
105
112
  {next ? (
@@ -108,10 +115,9 @@ export default async function FlowPage({ params }: { params: Promise<{ year: str
108
115
  className="group text-right no-underline"
109
116
  >
110
117
  <span className="text-xs text-muted">{t('newer')}</span>
111
- <div className="text-sm font-medium text-heading group-hover:text-accent transition-colors truncate">
112
- {next.title}
118
+ <div className="text-sm font-mono text-heading group-hover:text-accent transition-colors">
119
+ {next.date}
113
120
  </div>
114
- <span className="text-xs font-mono text-muted">{next.date}</span>
115
121
  </Link>
116
122
  ) : <div />}
117
123
  </nav>
@@ -8,7 +8,9 @@ import PageHeader from '@/components/PageHeader';
8
8
  import FlowContent from '@/components/FlowContent';
9
9
 
10
10
  export function generateStaticParams() {
11
+ if (siteConfig.features?.flow?.enabled === false) return [{ year: '_', month: '_' }];
11
12
  const allFlows = getAllFlows();
13
+ if (allFlows.length === 0) return [{ year: '_', month: '_' }];
12
14
  const monthSet = new Set(allFlows.map(f => {
13
15
  const [year, month] = f.slug.split('/');
14
16
  return `${year}/${month}`;
@@ -30,6 +32,7 @@ export async function generateMetadata({ params }: { params: Promise<{ year: str
30
32
  }
31
33
 
32
34
  export default async function FlowsMonthPage({ params }: { params: Promise<{ year: string; month: string }> }) {
35
+ if (siteConfig.features?.flow?.enabled === false) notFound();
33
36
  const { year, month } = await params;
34
37
  const flows = getFlowsByMonth(year, month);
35
38
  if (flows.length === 0) notFound();
@@ -39,6 +42,20 @@ export default async function FlowsMonthPage({ params }: { params: Promise<{ yea
39
42
  const tags = getFlowTags();
40
43
  const monthLabel = new Date(parseInt(year), parseInt(month) - 1).toLocaleDateString('en-US', { month: 'long', year: 'numeric' });
41
44
 
45
+ const breadcrumb = (
46
+ <nav aria-label="Breadcrumb" className="flex items-center gap-1.5 text-sm text-muted">
47
+ <Link href="/flows" className="hover:text-accent no-underline">
48
+ {t('all_flows')}
49
+ </Link>
50
+ <span className="text-muted/40" aria-hidden="true">›</span>
51
+ <Link href={`/flows/${year}`} className="hover:text-accent no-underline">
52
+ {year}
53
+ </Link>
54
+ <span className="text-muted/40" aria-hidden="true">›</span>
55
+ <span className="text-foreground">{month}</span>
56
+ </nav>
57
+ );
58
+
42
59
  return (
43
60
  <div className="layout-main">
44
61
  <PageHeader
@@ -48,24 +65,12 @@ export default async function FlowsMonthPage({ params }: { params: Promise<{ yea
48
65
  subtitleParams={{ count: flows.length }}
49
66
  />
50
67
 
51
- {/* Breadcrumb navigation */}
52
- <nav className="flex items-center gap-1.5 text-sm text-muted mb-6">
53
- <Link href="/flows" className="hover:text-accent no-underline">
54
- {t('all_flows')}
55
- </Link>
56
- <span className="text-muted/40">›</span>
57
- <Link href={`/flows/${year}`} className="hover:text-accent no-underline">
58
- {year}
59
- </Link>
60
- <span className="text-muted/40">›</span>
61
- <span className="text-foreground">{month}</span>
62
- </nav>
63
-
64
68
  <FlowContent
65
69
  flows={flows}
66
70
  entryDates={entryDates}
67
71
  tags={tags}
68
72
  currentDate={`${year}-${month}-01`}
73
+ breadcrumb={breadcrumb}
69
74
  />
70
75
  </div>
71
76
  );
@@ -8,7 +8,9 @@ import PageHeader from '@/components/PageHeader';
8
8
  import FlowContent from '@/components/FlowContent';
9
9
 
10
10
  export function generateStaticParams() {
11
+ if (siteConfig.features?.flow?.enabled === false) return [{ year: '_' }];
11
12
  const allFlows = getAllFlows();
13
+ if (allFlows.length === 0) return [{ year: '_' }];
12
14
  const years = new Set(allFlows.map(f => f.slug.split('/')[0]));
13
15
  return Array.from(years).map(year => ({ year }));
14
16
  }
@@ -23,6 +25,7 @@ export async function generateMetadata({ params }: { params: Promise<{ year: str
23
25
  }
24
26
 
25
27
  export default async function FlowsYearPage({ params }: { params: Promise<{ year: string }> }) {
28
+ if (siteConfig.features?.flow?.enabled === false) notFound();
26
29
  const { year } = await params;
27
30
  const flows = getFlowsByYear(year);
28
31
  if (flows.length === 0) notFound();
@@ -44,38 +47,45 @@ export default async function FlowsYearPage({ params }: { params: Promise<{ year
44
47
  'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec',
45
48
  ];
46
49
 
47
- return (
48
- <div className="layout-main">
49
- <PageHeader
50
- titleKey="flows_in_year"
51
- titleParams={{ year }}
52
- subtitleKey="flow_subtitle"
53
- subtitleParams={{ count: flows.length }}
54
- />
55
-
56
- {/* Month navigation pills */}
57
- <div className="flex flex-wrap items-center gap-2 mb-6">
58
- <Link href="/flows" className="text-sm text-muted hover:text-accent no-underline">
59
- ← {t('all_flows')}
50
+ const breadcrumb = (
51
+ <div className="space-y-2">
52
+ <nav aria-label="Breadcrumb" className="flex items-center gap-1.5 text-sm text-muted">
53
+ <Link href="/flows" className="hover:text-accent no-underline">
54
+ {t('all_flows')}
60
55
  </Link>
61
- <span className="text-muted/30">|</span>
56
+ <span className="text-muted/40" aria-hidden="true">›</span>
57
+ <span className="text-foreground">{year}</span>
58
+ </nav>
59
+ <div className="flex flex-wrap gap-1.5">
62
60
  {sortedMonths.map(m => (
63
61
  <Link
64
62
  key={m}
65
63
  href={`/flows/${year}/${m}`}
66
- className="inline-flex items-center gap-1 px-3 py-1 text-xs rounded-full border border-muted/20 text-foreground hover:border-accent hover:text-accent no-underline transition-colors"
64
+ className="inline-flex items-center gap-1 px-2.5 py-0.5 text-xs rounded-full border border-muted/20 text-foreground hover:border-accent hover:text-accent no-underline transition-colors"
67
65
  >
68
66
  {monthNames[parseInt(m, 10) - 1]}
69
67
  <span className="text-muted text-[10px]">({monthCounts[m]})</span>
70
68
  </Link>
71
69
  ))}
72
70
  </div>
71
+ </div>
72
+ );
73
+
74
+ return (
75
+ <div className="layout-main">
76
+ <PageHeader
77
+ titleKey="flows_in_year"
78
+ titleParams={{ year }}
79
+ subtitleKey="flow_subtitle"
80
+ subtitleParams={{ count: flows.length }}
81
+ />
73
82
 
74
83
  <FlowContent
75
84
  flows={flows}
76
85
  entryDates={entryDates}
77
86
  tags={tags}
78
87
  currentDate={`${year}-01-01`}
88
+ breadcrumb={breadcrumb}
79
89
  />
80
90
  </div>
81
91
  );