@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.
- package/.github/workflows/ci.yml +1 -1
- package/CHANGELOG.md +63 -0
- package/CLAUDE.md +9 -18
- package/GEMINI.md +6 -0
- package/README.md +44 -0
- package/TODO.md +15 -3
- package/bun.lock +5 -3
- package/content/about.mdx +64 -10
- package/content/about.zh.mdx +66 -9
- package/content/books/sample-book/index.mdx +3 -3
- package/content/flows/2026/02/05.md +0 -1
- package/content/flows/2026/02/10.mdx +2 -1
- package/content/flows/2026/02/15.md +2 -1
- package/content/flows/2026/02/18.mdx +2 -1
- package/content/flows/2026/02/20.md +0 -1
- package/content/notes/algorithms-and-data-structures.mdx +51 -0
- package/content/notes/digital-garden-philosophy.mdx +36 -0
- package/content/notes/react-server-components.mdx +49 -0
- package/content/notes/tailwind-v4.mdx +45 -0
- package/content/notes/zettelkasten-method.mdx +33 -0
- package/content/series/digital-garden/01-philosophy.mdx +25 -12
- package/docs/ARCHITECTURE.md +9 -1
- package/docs/CONTRIBUTING.md +26 -0
- package/docs/DIGITAL_GARDEN.md +72 -0
- package/imports/README.md +45 -0
- package/package.json +12 -5
- package/scripts/generate-knowledge-graph.ts +162 -0
- package/scripts/import-book.ts +176 -0
- package/scripts/new-flow-from-chat.ts +238 -0
- package/scripts/new-flow.ts +0 -5
- package/scripts/new-note.ts +53 -0
- package/scripts/sync-book-chapters.ts +210 -0
- package/site.config.ts +30 -7
- package/src/app/authors/[author]/page.tsx +3 -1
- package/src/app/books/[slug]/[chapter]/page.tsx +2 -1
- package/src/app/books/[slug]/page.tsx +6 -5
- package/src/app/flows/[year]/[month]/[day]/page.tsx +35 -29
- package/src/app/flows/[year]/[month]/page.tsx +18 -13
- package/src/app/flows/[year]/page.tsx +25 -15
- package/src/app/flows/page/[page]/page.tsx +5 -9
- package/src/app/flows/page.tsx +5 -8
- package/src/app/globals.css +41 -0
- package/src/app/graph/page.tsx +21 -0
- package/src/app/layout.tsx +4 -2
- package/src/app/notes/[slug]/page.tsx +129 -0
- package/src/app/notes/page/[page]/page.tsx +60 -0
- package/src/app/notes/page.tsx +33 -0
- package/src/app/page/[page]/page.tsx +1 -0
- package/src/app/page.tsx +4 -5
- package/src/app/posts/[slug]/page.tsx +5 -2
- package/src/app/posts/page/[page]/page.tsx +4 -1
- package/src/app/search.json/route.ts +17 -3
- package/src/app/series/[slug]/page/[page]/page.tsx +1 -0
- package/src/app/series/[slug]/page.tsx +3 -3
- package/src/app/sitemap.ts +1 -1
- package/src/app/tags/[tag]/page.tsx +3 -3
- package/src/components/Backlinks.tsx +39 -0
- package/src/components/BookMobileNav.tsx +11 -11
- package/src/components/BookSidebar.tsx +17 -25
- package/src/components/BrowserDetectionBanner.tsx +96 -0
- package/src/components/FeaturedStoriesSection.tsx +1 -1
- package/src/components/FlowCalendarSidebar.tsx +4 -2
- package/src/components/FlowContent.tsx +4 -3
- package/src/components/FlowHubTabs.tsx +50 -0
- package/src/components/FlowTimelineEntry.tsx +7 -9
- package/src/components/KnowledgeGraph.tsx +324 -0
- package/src/components/LanguageProvider.tsx +14 -5
- package/src/components/MarkdownRenderer.tsx +13 -2
- package/src/components/Navbar.tsx +237 -10
- package/src/components/NoteContent.tsx +123 -0
- package/src/components/NoteSidebar.tsx +132 -0
- package/src/components/RecentNotesSection.tsx +6 -11
- package/src/components/Search.tsx +7 -3
- package/src/components/TagContentTabs.tsx +0 -1
- package/src/i18n/translations.ts +43 -17
- package/src/layouts/BookLayout.tsx +3 -3
- package/src/layouts/PostLayout.tsx +8 -3
- package/src/lib/i18n.ts +83 -6
- package/src/lib/markdown.ts +306 -19
- package/src/lib/remark-wikilinks.ts +59 -0
- package/src/lib/search-utils.ts +2 -1
- package/tests/unit/static-params.test.ts +238 -0
- 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: "
|
|
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: "
|
|
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
|
-
|
|
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: "
|
|
102
|
-
subtitle: { en: "
|
|
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:
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
134
|
+
<li key={ch.id}>
|
|
134
135
|
<Link
|
|
135
|
-
href={`/books/${book.slug}/${ch.
|
|
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.
|
|
152
|
-
href={`/books/${book.slug}/${item.
|
|
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-
|
|
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-
|
|
100
|
-
{prev.
|
|
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-
|
|
112
|
-
{next.
|
|
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
|
-
|
|
48
|
-
<div className="
|
|
49
|
-
<
|
|
50
|
-
|
|
51
|
-
|
|
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/
|
|
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-
|
|
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
|
);
|