@hutusi/amytis 1.6.0 → 1.8.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 (92) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/GEMINI.md +12 -2
  3. package/README.md +14 -0
  4. package/TODO.md +24 -16
  5. package/bun.lock +8 -3
  6. package/content/about.mdx +1 -0
  7. package/content/about.zh.mdx +21 -0
  8. package/content/flows/2026/02/05.md +0 -1
  9. package/content/flows/2026/02/10.mdx +2 -1
  10. package/content/flows/2026/02/15.md +2 -1
  11. package/content/flows/2026/02/18.mdx +2 -1
  12. package/content/flows/2026/02/20.md +15 -0
  13. package/content/links.mdx +42 -0
  14. package/content/links.zh.mdx +41 -0
  15. package/content/notes/algorithms-and-data-structures.mdx +51 -0
  16. package/content/notes/digital-garden-philosophy.mdx +36 -0
  17. package/content/notes/react-server-components.mdx +49 -0
  18. package/content/notes/tailwind-v4.mdx +45 -0
  19. package/content/notes/zettelkasten-method.mdx +33 -0
  20. package/content/posts/2026-02-20-i18n-routing-considerations.mdx +150 -0
  21. package/content/posts/multimedia-showcase/index.mdx +261 -0
  22. package/content/privacy.mdx +32 -0
  23. package/content/privacy.zh.mdx +32 -0
  24. package/docs/ARCHITECTURE.md +16 -0
  25. package/docs/CONTRIBUTING.md +11 -0
  26. package/docs/DIGITAL_GARDEN.md +64 -0
  27. package/package.json +8 -3
  28. package/scripts/copy-assets.ts +1 -1
  29. package/scripts/generate-knowledge-graph.ts +162 -0
  30. package/scripts/new-flow.ts +0 -5
  31. package/scripts/new-note.ts +53 -0
  32. package/site.config.ts +146 -44
  33. package/src/app/[slug]/page.tsx +0 -10
  34. package/src/app/archive/page.tsx +38 -10
  35. package/src/app/books/[slug]/page.tsx +18 -0
  36. package/src/app/flows/[year]/[month]/[day]/page.tsx +51 -31
  37. package/src/app/flows/[year]/[month]/page.tsx +15 -13
  38. package/src/app/flows/[year]/page.tsx +22 -15
  39. package/src/app/flows/page/[page]/page.tsx +3 -9
  40. package/src/app/flows/page.tsx +3 -8
  41. package/src/app/globals.css +41 -0
  42. package/src/app/graph/page.tsx +19 -0
  43. package/src/app/layout.tsx +47 -21
  44. package/src/app/notes/[slug]/page.tsx +128 -0
  45. package/src/app/notes/page/[page]/page.tsx +58 -0
  46. package/src/app/notes/page.tsx +31 -0
  47. package/src/app/page.tsx +134 -72
  48. package/src/app/posts/[slug]/page.tsx +8 -12
  49. package/src/app/search.json/route.ts +15 -1
  50. package/src/app/series/[slug]/page.tsx +18 -0
  51. package/src/app/subscribe/page.tsx +17 -0
  52. package/src/app/tags/[tag]/page.tsx +9 -26
  53. package/src/app/tags/page.tsx +3 -8
  54. package/src/components/AuthorCard.tsx +43 -0
  55. package/src/components/Backlinks.tsx +39 -0
  56. package/src/components/Comments.tsx +20 -4
  57. package/src/components/ExternalLinks.tsx +6 -2
  58. package/src/components/FlowCalendarSidebar.tsx +4 -2
  59. package/src/components/FlowContent.tsx +4 -3
  60. package/src/components/FlowHubTabs.tsx +50 -0
  61. package/src/components/FlowTimelineEntry.tsx +7 -9
  62. package/src/components/Footer.tsx +35 -26
  63. package/src/components/KnowledgeGraph.tsx +324 -0
  64. package/src/components/LanguageProvider.tsx +0 -5
  65. package/src/components/LanguageSwitch.tsx +117 -6
  66. package/src/components/LocaleSwitch.tsx +33 -0
  67. package/src/components/MarkdownRenderer.tsx +13 -2
  68. package/src/components/Navbar.tsx +266 -17
  69. package/src/components/NoteContent.tsx +123 -0
  70. package/src/components/NoteSidebar.tsx +132 -0
  71. package/src/components/PostNavigation.tsx +55 -0
  72. package/src/components/PostSidebar.tsx +172 -126
  73. package/src/components/ReadingProgressBar.tsx +6 -21
  74. package/src/components/RecentNotesSection.tsx +6 -11
  75. package/src/components/RelatedPosts.tsx +1 -1
  76. package/src/components/Search.tsx +29 -5
  77. package/src/components/SelectedBooksSection.tsx +12 -6
  78. package/src/components/ShareBar.tsx +115 -0
  79. package/src/components/SimpleLayoutHeader.tsx +5 -14
  80. package/src/components/SubscribePage.tsx +298 -0
  81. package/src/components/TagContentTabs.tsx +102 -0
  82. package/src/components/TagPageHeader.tsx +7 -13
  83. package/src/components/TagSidebar.tsx +142 -0
  84. package/src/components/TagsIndexClient.tsx +156 -0
  85. package/src/hooks/useScrollY.ts +41 -0
  86. package/src/i18n/translations.ts +105 -1
  87. package/src/layouts/PostLayout.tsx +40 -8
  88. package/src/layouts/SimpleLayout.tsx +53 -15
  89. package/src/lib/markdown.ts +347 -18
  90. package/src/lib/remark-wikilinks.ts +59 -0
  91. package/src/lib/search-utils.ts +2 -1
  92. package/src/components/TableOfContents.tsx +0 -158
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hutusi/amytis",
3
- "version": "1.6.0",
3
+ "version": "1.8.0",
4
4
  "description": "A high-performance digital garden and blog engine with Next.js 16 and Tailwind CSS v4",
5
5
  "repository": {
6
6
  "type": "git",
@@ -14,8 +14,9 @@
14
14
  "packageManager": "bun@1.3.4",
15
15
  "scripts": {
16
16
  "dev": "next dev",
17
- "build": "bun scripts/copy-assets.ts && next build && next-image-export-optimizer && pagefind --site out",
18
- "build:dev": "bun scripts/copy-assets.ts && next build && pagefind --site out --output-path public/pagefind",
17
+ "build": "bun scripts/copy-assets.ts && bun run build:graph && next build && next-image-export-optimizer && pagefind --site out",
18
+ "build:dev": "bun scripts/copy-assets.ts && bun run build:graph && next build && pagefind --site out --output-path public/pagefind",
19
+ "build:graph": "NODE_ENV=production bun scripts/generate-knowledge-graph.ts",
19
20
  "validate": "bun run lint && bun run test && bun run build:dev",
20
21
  "clean": "rm -rf .next out public/posts public/books public/flows",
21
22
  "new": "bun scripts/new-post.ts",
@@ -24,6 +25,7 @@
24
25
  "new-from-pdf": "bun scripts/new-from-pdf.ts",
25
26
  "new-from-images": "bun scripts/new-from-images.ts",
26
27
  "new-flow": "bun scripts/new-flow.ts",
28
+ "new-note": "bun scripts/new-note.ts",
27
29
  "series-draft": "bun scripts/series-draft.ts",
28
30
  "start": "next start",
29
31
  "lint": "eslint",
@@ -35,6 +37,7 @@
35
37
  "dependencies": {
36
38
  "@giscus/react": "^3.1.0",
37
39
  "@tailwindcss/typography": "^0.5.19",
40
+ "d3": "^7.9.0",
38
41
  "github-slugger": "^2.0.0",
39
42
  "gray-matter": "^4.0.3",
40
43
  "image-size": "^2.0.2",
@@ -44,6 +47,7 @@
44
47
  "next-themes": "^0.4.6",
45
48
  "react": "19.2.4",
46
49
  "react-dom": "19.2.4",
50
+ "react-icons": "^5.5.0",
47
51
  "react-markdown": "^10.1.0",
48
52
  "react-syntax-highlighter": "^16.1.0",
49
53
  "rehype-katex": "^7.0.1",
@@ -57,6 +61,7 @@
57
61
  "devDependencies": {
58
62
  "@tailwindcss/postcss": "^4.1.18",
59
63
  "@types/bun": "^1.3.9",
64
+ "@types/d3": "^7.4.3",
60
65
  "@types/image-size": "^0.8.0",
61
66
  "@types/node": "^24.10.13",
62
67
  "@types/react": "^19.2.14",
@@ -51,7 +51,7 @@ function getSlugFromFilename(filename: string): string {
51
51
  const dateRegex = /^(\d{4}-\d{2}-\d{2})-(.*)$/;
52
52
  const match = nameWithoutExt.match(dateRegex);
53
53
 
54
- if (match && !siteConfig.includeDateInUrl) {
54
+ if (match && !siteConfig.posts?.includeDateInUrl) {
55
55
  return match[2];
56
56
  }
57
57
  return nameWithoutExt;
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Generate public/knowledge-graph.json for the Knowledge Graph visualization.
3
+ *
4
+ * Nodes: all posts, all notes, and flows that appear as wikilink source/target.
5
+ * Edges: wikilink edges (from backlink index) + series membership edges.
6
+ *
7
+ * Run: NODE_ENV=production bun scripts/generate-knowledge-graph.ts
8
+ */
9
+
10
+ import fs from 'fs';
11
+ import path from 'path';
12
+ import { getAllPosts, getAllNotes, getAllFlows, getSeriesData } from '../src/lib/markdown';
13
+
14
+ interface GraphNode {
15
+ id: string;
16
+ title: string;
17
+ type: 'post' | 'note' | 'flow' | 'series';
18
+ url: string;
19
+ connections: number;
20
+ }
21
+
22
+ interface GraphEdge {
23
+ source: string;
24
+ target: string;
25
+ type: 'wikilink' | 'series';
26
+ }
27
+
28
+ function extractWikilinks(content: string): string[] {
29
+ const slugs: string[] = [];
30
+ const re = /\[\[([^\]|]+?)(?:\|[^\]]+?)?\]\]/g;
31
+ let match;
32
+ while ((match = re.exec(content)) !== null) {
33
+ slugs.push(match[1].trim());
34
+ }
35
+ return slugs;
36
+ }
37
+
38
+ async function main() {
39
+ console.log('Generating knowledge graph…');
40
+
41
+ const posts = getAllPosts();
42
+ const notes = getAllNotes();
43
+ const flows = getAllFlows();
44
+
45
+ // Build id→node map
46
+ const nodeMap = new Map<string, GraphNode>();
47
+ const edges: GraphEdge[] = [];
48
+
49
+ // Add all posts and notes
50
+ for (const post of posts) {
51
+ nodeMap.set(post.slug, { id: post.slug, title: post.title, type: 'post', url: `/posts/${post.slug}`, connections: 0 });
52
+ }
53
+ for (const note of notes) {
54
+ nodeMap.set(note.slug, { id: note.slug, title: note.title, type: 'note', url: `/notes/${note.slug}`, connections: 0 });
55
+ }
56
+
57
+ // Track which flows appear in wikilinks (to avoid including ALL flows)
58
+ const linkedFlowSlugs = new Set<string>();
59
+
60
+ // Scan all content for wikilinks
61
+ const allContent: Array<{ slug: string; title: string; type: 'post' | 'note' | 'flow'; content: string; url: string }> = [
62
+ ...posts.map(p => ({ slug: p.slug, title: p.title, type: 'post' as const, content: p.content, url: `/posts/${p.slug}` })),
63
+ ...notes.map(n => ({ slug: n.slug, title: n.title, type: 'note' as const, content: n.content, url: `/notes/${n.slug}` })),
64
+ ...flows.map(f => ({ slug: f.slug, title: f.title, type: 'flow' as const, content: f.content, url: `/flows/${f.slug}` })),
65
+ ];
66
+
67
+ // Build wikilink edges (deduplicate per source document)
68
+ for (const item of allContent) {
69
+ const targets = extractWikilinks(item.content);
70
+ const seenTargets = new Set<string>();
71
+ for (const target of targets) {
72
+ if (target === item.slug) continue; // skip self
73
+ if (seenTargets.has(target)) continue; // skip duplicate within this doc
74
+ seenTargets.add(target);
75
+ edges.push({ source: item.slug, target, type: 'wikilink' });
76
+
77
+ // Ensure source exists in nodeMap
78
+ if (!nodeMap.has(item.slug)) {
79
+ nodeMap.set(item.slug, { id: item.slug, title: item.title, type: item.type, url: item.url, connections: 0 });
80
+ if (item.type === 'flow') linkedFlowSlugs.add(item.slug);
81
+ }
82
+ // Track referenced flows
83
+ const targetFlow = flows.find(f => f.slug === target);
84
+ if (targetFlow) {
85
+ linkedFlowSlugs.add(target);
86
+ if (!nodeMap.has(target)) {
87
+ nodeMap.set(target, { id: target, title: targetFlow.title, type: 'flow', url: `/flows/${target}`, connections: 0 });
88
+ }
89
+ }
90
+ }
91
+ }
92
+
93
+ // Add series nodes + series membership edges
94
+ const seriesSlugsSet = new Set<string>();
95
+ for (const post of posts) {
96
+ if (post.series) seriesSlugsSet.add(post.series);
97
+ }
98
+
99
+ for (const seriesSlug of seriesSlugsSet) {
100
+ const seriesData = getSeriesData(seriesSlug);
101
+ const seriesId = `series:${seriesSlug}`;
102
+ nodeMap.set(seriesId, {
103
+ id: seriesId,
104
+ title: seriesData?.title || seriesSlug,
105
+ type: 'series',
106
+ url: `/series/${seriesSlug}`,
107
+ connections: 0,
108
+ });
109
+ // Add edges from series to each post
110
+ for (const post of posts) {
111
+ if (post.series === seriesSlug) {
112
+ edges.push({ source: seriesId, target: post.slug, type: 'series' });
113
+ }
114
+ }
115
+ }
116
+
117
+ // Compute connection counts
118
+ for (const edge of edges) {
119
+ const src = nodeMap.get(edge.source);
120
+ if (src) src.connections++;
121
+ const tgt = nodeMap.get(edge.target);
122
+ if (tgt) tgt.connections++;
123
+ }
124
+
125
+ // Filter out nodes with no edges (isolated) to keep graph clean
126
+ const connectedIds = new Set<string>();
127
+ for (const edge of edges) {
128
+ connectedIds.add(edge.source);
129
+ connectedIds.add(edge.target);
130
+ }
131
+
132
+ // Always include all notes and posts (they are the knowledge base)
133
+ const nodes = Array.from(nodeMap.values()).filter(n =>
134
+ n.type === 'note' || n.type === 'post' || n.type === 'series' || connectedIds.has(n.id)
135
+ );
136
+
137
+ // Filter edges to only include those with both source and target in nodes
138
+ const validIds = new Set(nodes.map(n => n.id));
139
+ const validEdges = edges.filter(e => validIds.has(e.source) && validIds.has(e.target));
140
+
141
+ // Recompute connection counts from validEdges only (pre-filter counts were inflated)
142
+ const connectionCounts = new Map<string, number>();
143
+ for (const edge of validEdges) {
144
+ connectionCounts.set(edge.source, (connectionCounts.get(edge.source) ?? 0) + 1);
145
+ connectionCounts.set(edge.target, (connectionCounts.get(edge.target) ?? 0) + 1);
146
+ }
147
+ for (const node of nodes) {
148
+ node.connections = connectionCounts.get(node.id) ?? 0;
149
+ }
150
+
151
+ const graphData = { nodes, edges: validEdges };
152
+
153
+ const outputPath = path.join(process.cwd(), 'public', 'knowledge-graph.json');
154
+ fs.writeFileSync(outputPath, JSON.stringify(graphData, null, 2));
155
+
156
+ console.log(`✓ Written ${nodes.length} nodes, ${validEdges.length} edges → ${outputPath}`);
157
+ }
158
+
159
+ main().catch(err => {
160
+ console.error('Error generating knowledge graph:', err);
161
+ process.exit(1);
162
+ });
@@ -2,14 +2,12 @@ import fs from 'fs';
2
2
  import path from 'path';
3
3
 
4
4
  const args = process.argv.slice(2);
5
- const title = args.filter(arg => !arg.startsWith('--'))[0];
6
5
  const useMdx = args.includes('--mdx');
7
6
 
8
7
  const now = new Date();
9
8
  const year = String(now.getFullYear());
10
9
  const month = String(now.getMonth() + 1).padStart(2, '0');
11
10
  const day = String(now.getDate()).padStart(2, '0');
12
- const dateStr = `${year}-${month}-${day}`;
13
11
 
14
12
  const ext = useMdx ? '.mdx' : '.md';
15
13
  const dirPath = path.join(process.cwd(), 'content', 'flows', year, month);
@@ -34,10 +32,7 @@ if (!fs.existsSync(dirPath)) {
34
32
  fs.mkdirSync(dirPath, { recursive: true });
35
33
  }
36
34
 
37
- const flowTitle = title || dateStr;
38
-
39
35
  const content = `---
40
- title: "${flowTitle}"
41
36
  tags: []
42
37
  ---
43
38
 
@@ -0,0 +1,53 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+
4
+ const args = process.argv.slice(2);
5
+ const titleArg = args.filter(arg => !arg.startsWith('--'))[0];
6
+ const useMd = args.includes('--md');
7
+
8
+ if (!titleArg) {
9
+ console.error('Usage: bun run new-note "Note Title"');
10
+ process.exit(1);
11
+ }
12
+
13
+ // Slugify title
14
+ const slug = titleArg
15
+ .toLowerCase()
16
+ .replace(/[^a-z0-9\u4e00-\u9fff]+/g, '-')
17
+ .replace(/^-|-$/g, '');
18
+
19
+ const now = new Date();
20
+ const dateStr = now.toISOString().split('T')[0];
21
+ const ext = useMd ? '.md' : '.mdx';
22
+
23
+ const notesDir = path.join(process.cwd(), 'content', 'notes');
24
+ const targetPath = path.join(notesDir, `${slug}${ext}`);
25
+
26
+ const altExt = useMd ? '.mdx' : '.md';
27
+ const altPath = path.join(notesDir, `${slug}${altExt}`);
28
+
29
+ if (fs.existsSync(targetPath)) {
30
+ console.error(`Error: Note already exists at ${targetPath}`);
31
+ process.exit(1);
32
+ }
33
+
34
+ if (fs.existsSync(altPath)) {
35
+ console.error(`Error: Note already exists at ${altPath}`);
36
+ process.exit(1);
37
+ }
38
+
39
+ if (!fs.existsSync(notesDir)) {
40
+ fs.mkdirSync(notesDir, { recursive: true });
41
+ }
42
+
43
+ const content = `---
44
+ title: "${titleArg}"
45
+ date: "${dateStr}"
46
+ tags: []
47
+ aliases: []
48
+ ---
49
+
50
+ `;
51
+
52
+ fs.writeFileSync(targetPath, content);
53
+ console.log(`Created new note: ${targetPath}`);
package/site.config.ts CHANGED
@@ -1,67 +1,160 @@
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
+
8
+ export interface NavItem {
9
+ name: string;
10
+ url: string;
11
+ weight: number;
12
+ external?: boolean;
13
+ dropdown?: string[];
14
+ children?: NavChildItem[]; // static sub-links rendered as a dropdown
15
+ }
16
+
17
+ // Defined up-front so footer.connect can reference these URLs without duplication
18
+ const social = {
19
+ github: "https://github.com/hutusi/amytis",
20
+ twitter: "https://twitter.com/hutusi",
21
+ email: "mailto:huziyong@gmail.com",
22
+ };
23
+
1
24
  export const siteConfig = {
25
+
26
+ // ── Site identity ─────────────────────────────────────────────────────────
2
27
  title: { en: "Amytis", zh: "Amytis" },
3
28
  description: { en: "A minimalist digital garden for growing thoughts and sharing knowledge.", zh: "一个极简的数字花园,用于培育思想和分享知识。" },
4
29
  baseUrl: "https://example.com", // Replace with your actual domain
30
+ ogImage: "/og-image.png", // Default OG/social preview image — place a 1200×630 PNG at public/og-image.png
5
31
  footerText: { en: `© ${new Date().getFullYear()} Amytis. All rights reserved.`, zh: `© ${new Date().getFullYear()} Amytis. 保留所有权利。` },
6
- nav: [
7
- { name: "Home", url: "/", weight: 1 },
8
- { name: "Flow", url: "/flows", weight: 1.1 },
9
- { name: "Books", url: "/books", weight: 1.3 },
10
- { name: "Series", url: "/series", weight: 1.5 },
11
- { name: "Archive", url: "/archive", weight: 2 },
12
- { name: "Tags", url: "/tags", weight: 3 },
13
- { name: "About", url: "/about", weight: 4 },
14
- ],
15
- social: {
16
- github: "https://github.com/hutusi/amytis",
17
- twitter: "https://twitter.com/hutusi",
18
- email: "mailto:huziyong@gmail.com",
32
+
33
+ // ── i18n ──────────────────────────────────────────────────────────────────
34
+ i18n: {
35
+ defaultLocale: 'en',
36
+ locales: ['en', 'zh'],
19
37
  },
20
- series: {
21
- navbar: ["digital-garden", "markdown-showcase", "ai-nexus-weekly"], // Slugs of series to show in navbar
38
+
39
+ // ── Navigation ────────────────────────────────────────────────────────────
40
+ nav: [
41
+ { name: "Flow", url: "/flows", weight: 1 },
42
+ { name: "Posts", url: "/posts", weight: 2 },
43
+ { name: "Series", url: "/series", weight: 3, dropdown: ["digital-garden", "markdown-showcase", "ai-nexus-weekly"] },
44
+ { name: "Books", url: "/books", weight: 4, dropdown: [] },
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
+ ]},
52
+ ] as NavItem[],
53
+
54
+ // ── Footer ────────────────────────────────────────────────────────────────
55
+ footer: {
56
+ explore: [
57
+ { name: "Archive", url: "/archive", weight: 1 },
58
+ { name: "Tags", url: "/tags", weight: 2 },
59
+ { name: "Links", url: "/links", weight: 3 },
60
+ { name: "About", url: "/about", weight: 4 },
61
+ ],
62
+ connect: [
63
+ { name: "GitHub", url: social.github, weight: 1 },
64
+ { name: "Twitter", url: social.twitter, weight: 2 },
65
+ { name: "RSS Feed", url: "/feed.xml", weight: 3 },
66
+ { name: "Subscribe", url: "/subscribe", weight: 4 },
67
+ ],
68
+ builtWith: {
69
+ show: true,
70
+ url: "https://github.com/hutusi/amytis",
71
+ text: { en: "Built with Amytis", zh: "基于 Amytis 构建" },
72
+ },
22
73
  },
23
- books: {
24
- navbar: [] as string[], // Slugs of books to show in navbar dropdown
74
+
75
+ // ── Social & sharing ──────────────────────────────────────────────────────
76
+ social,
77
+ share: {
78
+ enabled: true,
79
+ // Supported: twitter, facebook, linkedin, weibo, reddit, hackernews,
80
+ // telegram, bluesky, mastodon, douban, zhihu, copy
81
+ platforms: ['twitter', 'facebook', 'linkedin', 'weibo', 'copy'],
25
82
  },
26
- archive: {
27
- showAuthors: true,
83
+ subscribe: {
84
+ substack: '', // Substack publication URL, e.g., 'https://yourname.substack.com'
85
+ telegram: '', // Telegram channel URL, e.g., 'https://t.me/yourchannel'
86
+ wechat: {
87
+ qrCode: '', // Path to QR image in public/, e.g., '/images/wechat-qr.png'
88
+ account: '', // WeChat official account ID/name shown below QR
89
+ },
90
+ email: '', // Newsletter/mailing list URL (distinct from social.email contact address)
28
91
  },
29
- pagination: {
30
- posts: 5,
31
- series: 1,
32
- flows: 20,
92
+
93
+ // ── Features ──────────────────────────────────────────────────────────────
94
+ features: {
95
+ posts: {
96
+ enabled: true,
97
+ name: { en: "Posts", zh: "文章" },
98
+ },
99
+ series: {
100
+ enabled: true,
101
+ name: { en: "Series", zh: "系列" },
102
+ },
103
+ books: {
104
+ enabled: true,
105
+ name: { en: "Books", zh: "书籍" },
106
+ },
107
+ flows: {
108
+ enabled: true,
109
+ name: { en: "Flow", zh: "随笔" },
110
+ },
111
+ notes: {
112
+ enabled: true,
113
+ name: { en: "Notes", zh: "笔记" },
114
+ },
33
115
  },
34
- includeDateInUrl: false,
35
- // trailingSlash is configured in next.config.ts (Next.js handles URL normalization)
36
- showFuturePosts: false,
37
- toc: true,
38
- themeColor: 'default', // 'default' | 'blue' | 'rose' | 'amber'
116
+
117
+ // ── Homepage ──────────────────────────────────────────────────────────────
39
118
  hero: {
40
119
  tagline: { en: "Digital Garden", zh: "数字花园" },
41
120
  title: { en: "Cultivating Digital Knowledge", zh: "培育数字知识" },
42
121
  subtitle: { en: "A minimalist digital garden for growing thoughts and sharing knowledge.", zh: "一个极简的数字花园,用于培育思想和分享知识。" },
43
122
  },
44
- about: {
45
- title: { en: "About Amytis", zh: "关于 Amytis" },
46
- subtitle: { en: "Learn more about the philosophy and technology behind this digital garden.", zh: "了解这座数字花园背后的理念与技术。" },
123
+ homepage: {
124
+ sections: [
125
+ { id: 'hero', enabled: true, weight: 1 },
126
+ { id: 'featured-series', enabled: true, weight: 2, maxItems: 6, scrollThreshold: 2 },
127
+ { id: 'featured-books', enabled: true, weight: 3, maxItems: 4, scrollThreshold: 2 },
128
+ { id: 'featured-posts', enabled: true, weight: 4, maxItems: 4, scrollThreshold: 1 },
129
+ { id: 'latest-posts', enabled: true, weight: 5, maxItems: 5 },
130
+ { id: 'recent-flows', enabled: true, weight: 6, maxItems: 5 },
131
+ ],
47
132
  },
48
- flows: {
49
- recentCount: 5,
133
+
134
+ // ── Content ───────────────────────────────────────────────────────────────
135
+ pagination: {
136
+ posts: 5,
137
+ series: 5,
138
+ flows: 20,
139
+ notes: 20,
50
140
  },
51
- featured: {
52
- series: {
53
- scrollThreshold: 2, // Enable scrolling when more than this number
54
- maxItems: 6,
55
- },
56
- stories: {
57
- scrollThreshold: 1, // Enable scrolling when more than this number
58
- maxItems: 4,
141
+ posts: {
142
+ toc: true,
143
+ showFuturePosts: false,
144
+ includeDateInUrl: false,
145
+ // trailingSlash is configured in next.config.ts (Next.js handles URL normalization)
146
+ archive: {
147
+ showAuthors: true,
59
148
  },
60
149
  },
61
- i18n: {
62
- defaultLocale: 'en',
63
- locales: ['en', 'zh'],
150
+ flows: {
151
+ recentCount: 5,
64
152
  },
153
+
154
+ // ── Appearance ────────────────────────────────────────────────────────────
155
+ themeColor: 'default', // 'default' | 'blue' | 'rose' | 'amber'
156
+
157
+ // ── Analytics ─────────────────────────────────────────────────────────────
65
158
  analytics: {
66
159
  provider: 'umami', // 'umami' | 'plausible' | 'google' | null
67
160
  umami: {
@@ -76,6 +169,8 @@ export const siteConfig = {
76
169
  measurementId: '', // G-XXXXXXXXXX
77
170
  },
78
171
  },
172
+
173
+ // ── Comments ──────────────────────────────────────────────────────────────
79
174
  comments: {
80
175
  provider: 'giscus', // 'giscus' | 'disqus' | null
81
176
  giscus: {
@@ -88,4 +183,11 @@ export const siteConfig = {
88
183
  shortname: '',
89
184
  },
90
185
  },
186
+
187
+ // ── Authors ───────────────────────────────────────────────────────────────
188
+ authors: {
189
+ // Map display name (as used in post frontmatter) to author profile
190
+ // "Author Name": { bio: "Short bio shown in author card below each post." },
191
+ } as Record<string, { bio?: string }>,
192
+
91
193
  };
@@ -53,15 +53,5 @@ export default async function Page({
53
53
  return <PostLayout post={page} />;
54
54
  }
55
55
 
56
- if (slug === 'about' && siteConfig.about) {
57
- return (
58
- <SimpleLayout
59
- post={page}
60
- titleOverride={siteConfig.about.title}
61
- subtitleOverride={siteConfig.about.subtitle}
62
- />
63
- );
64
- }
65
-
66
56
  return <SimpleLayout post={page} />;
67
57
  }
@@ -1,7 +1,7 @@
1
1
  import Link from 'next/link';
2
- import { getAllPosts, PostData } from '@/lib/markdown';
2
+ import { getAllPosts, getSeriesData, PostData } from '@/lib/markdown';
3
3
  import { siteConfig } from '../../../site.config';
4
- import { resolveLocale } from '@/lib/i18n';
4
+ import { resolveLocale, t } from '@/lib/i18n';
5
5
  import PageHeader from '@/components/PageHeader';
6
6
 
7
7
  export const metadata = {
@@ -41,12 +41,20 @@ function groupPostsByDate(posts: PostData[]): GroupedPosts {
41
41
  export default function ArchivePage() {
42
42
  const posts = getAllPosts();
43
43
  const groupedPosts = groupPostsByDate(posts);
44
- const showAuthors = siteConfig.archive?.showAuthors;
44
+ const showAuthors = siteConfig.posts?.archive?.showAuthors;
45
45
 
46
46
  // Sort years descending to show newest content first
47
47
  const years = Object.keys(groupedPosts).sort((a, b) => Number(b) - Number(a));
48
48
  const totalPosts = posts.length;
49
49
 
50
+ // Build series slug → title map (one lookup per unique series)
51
+ const seriesSlugs = [...new Set(posts.filter(p => p.series).map(p => p.series!))];
52
+ const seriesTitleMap: Record<string, string> = {};
53
+ for (const slug of seriesSlugs) {
54
+ const data = getSeriesData(slug);
55
+ if (data) seriesTitleMap[slug] = data.title;
56
+ }
57
+
50
58
  return (
51
59
  <div className="layout-main">
52
60
  <PageHeader
@@ -57,7 +65,25 @@ export default function ArchivePage() {
57
65
  subtitleParams={{ count: totalPosts, years: years.length }}
58
66
  />
59
67
 
60
- <main className="max-w-4xl mx-auto">
68
+ <main>
69
+ {/* Year-jump navigation — mirrors content grid to align with timeline column */}
70
+ {years.length > 1 && (
71
+ <div className="grid grid-cols-1 md:grid-cols-[200px_1fr] gap-8 md:gap-16 mb-16">
72
+ <div />
73
+ <nav aria-label="Jump to year" className="flex flex-wrap items-center gap-2">
74
+ {years.map(year => (
75
+ <a
76
+ key={year}
77
+ href={`#${year}`}
78
+ className="text-xs font-mono text-muted hover:text-accent border border-muted/20 hover:border-accent/40 rounded px-3 py-1 transition-all duration-200 no-underline"
79
+ >
80
+ {year}
81
+ </a>
82
+ ))}
83
+ </nav>
84
+ </div>
85
+ )}
86
+
61
87
  <div className="space-y-24">
62
88
  {years.map((year) => {
63
89
  // Sort months within the year in descending order (December -> January)
@@ -67,7 +93,7 @@ export default function ArchivePage() {
67
93
  const yearTotal = months.reduce((total, month) => total + groupedPosts[year][month].length, 0);
68
94
 
69
95
  return (
70
- <section key={year} className="relative grid grid-cols-1 md:grid-cols-[200px_1fr] gap-8 md:gap-16">
96
+ <section key={year} id={year} className="relative grid grid-cols-1 md:grid-cols-[200px_1fr] gap-8 md:gap-16">
71
97
  {/* Year Marker */}
72
98
  <div className="relative">
73
99
  <div className="sticky top-24 lg:top-32 text-left md:text-right">
@@ -75,7 +101,7 @@ export default function ArchivePage() {
75
101
  {year}
76
102
  </h2>
77
103
  <span className="block text-xs font-bold uppercase tracking-widest text-muted mt-2">
78
- {yearTotal} Posts
104
+ {yearTotal} {t('posts')}
79
105
  </span>
80
106
  </div>
81
107
  </div>
@@ -91,7 +117,9 @@ export default function ArchivePage() {
91
117
 
92
118
  <h3 className="text-base font-sans font-bold uppercase tracking-widest text-accent mb-8">
93
119
  {getMonthName(Number(month))}
94
- <span className="ml-2 text-xs font-normal text-muted/60">({monthPosts.length})</span>
120
+ <span className="ml-2 inline-flex items-center text-[10px] font-mono text-muted/60 bg-muted/10 rounded px-1.5 py-0.5 align-middle leading-none">
121
+ {monthPosts.length}
122
+ </span>
95
123
  </h3>
96
124
 
97
125
  <ul className="space-y-6">
@@ -113,10 +141,10 @@ export default function ArchivePage() {
113
141
  </h4>
114
142
  {post.series && (
115
143
  <span
116
- title={post.series}
117
- className="text-[10px] font-sans font-medium uppercase tracking-wider text-accent/60 border border-accent/20 rounded px-1.5 py-0.5 shrink-0 leading-none max-w-[10ch] truncate inline-block align-baseline"
144
+ title={seriesTitleMap[post.series] ?? post.series}
145
+ className="text-[10px] font-sans font-medium uppercase tracking-wider text-accent/60 border border-accent/20 rounded px-1.5 py-0.5 shrink-0 leading-none max-w-[14ch] truncate inline-block align-baseline"
118
146
  >
119
- {post.series}
147
+ {seriesTitleMap[post.series] ?? post.series}
120
148
  </span>
121
149
  )}
122
150
  </div>
@@ -22,9 +22,27 @@ export async function generateMetadata({ params }: { params: Promise<{ slug: str
22
22
  return { title: 'Book Not Found' };
23
23
  }
24
24
 
25
+ const ogImage = book.coverImage && !book.coverImage.startsWith('text:') && !book.coverImage.startsWith('./')
26
+ ? book.coverImage
27
+ : siteConfig.ogImage;
28
+
25
29
  return {
26
30
  title: `${book.title} | ${resolveLocale(siteConfig.title)}`,
27
31
  description: book.excerpt,
32
+ openGraph: {
33
+ title: book.title,
34
+ description: book.excerpt,
35
+ type: 'website',
36
+ url: `${siteConfig.baseUrl}/books/${slug}`,
37
+ siteName: resolveLocale(siteConfig.title),
38
+ images: [{ url: ogImage, width: 1200, height: 630, alt: book.title }],
39
+ },
40
+ twitter: {
41
+ card: ogImage !== siteConfig.ogImage ? 'summary_large_image' : 'summary',
42
+ title: book.title,
43
+ description: book.excerpt,
44
+ images: [ogImage],
45
+ },
28
46
  };
29
47
  }
30
48