@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,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for generateStaticParams — verifies that every dynamic route with
|
|
3
|
+
* `dynamicParams = false` returns a non-empty placeholder array when content
|
|
4
|
+
* directories are empty, rather than returning [] which would cause Next.js
|
|
5
|
+
* static export (`output: export`) to fail at build time.
|
|
6
|
+
*
|
|
7
|
+
* Isolation strategy
|
|
8
|
+
* ──────────────────
|
|
9
|
+
* bun:test loads all test files before running any tests. A module-level
|
|
10
|
+
* mock.module() call runs at load time and would replace @/lib/markdown in the
|
|
11
|
+
* shared module registry before integration test files resolve their static
|
|
12
|
+
* imports — causing those tests to see empty stubs instead of real content.
|
|
13
|
+
*
|
|
14
|
+
* To avoid this:
|
|
15
|
+
* • Next.js / component mocks stay at module level — integration tests never
|
|
16
|
+
* import those, so they are harmless.
|
|
17
|
+
* • @/lib/markdown is mocked inside beforeAll, which runs AFTER all files
|
|
18
|
+
* have finished loading and resolving their static imports.
|
|
19
|
+
* • Page files are loaded via await import() inside each test, which runs
|
|
20
|
+
* after beforeAll, so they pick up the mock correctly.
|
|
21
|
+
* • afterAll restores the real module so any subsequent tests see real data.
|
|
22
|
+
*/
|
|
23
|
+
import { describe, test, expect, mock, beforeAll, afterAll } from 'bun:test';
|
|
24
|
+
|
|
25
|
+
// ─── Capture real markdown module ────────────────────────────────────────────
|
|
26
|
+
// Static imports are hoisted and resolved before any executable code (including
|
|
27
|
+
// beforeAll / mock.module calls), so this always captures the real module.
|
|
28
|
+
import * as realMarkdown from '../../src/lib/markdown';
|
|
29
|
+
|
|
30
|
+
// ─── Next.js runtime stubs (module-level — safe) ─────────────────────────────
|
|
31
|
+
mock.module('next/navigation', () => ({
|
|
32
|
+
notFound: () => { throw new Error('NOT_FOUND'); },
|
|
33
|
+
redirect: () => { throw new Error('REDIRECT'); },
|
|
34
|
+
usePathname: () => '/',
|
|
35
|
+
useRouter: () => ({}),
|
|
36
|
+
useSearchParams: () => new URLSearchParams(),
|
|
37
|
+
}));
|
|
38
|
+
|
|
39
|
+
mock.module('next/link', () => ({ default: 'a' }));
|
|
40
|
+
mock.module('next/image', () => ({ default: 'img' }));
|
|
41
|
+
|
|
42
|
+
// ─── i18n stub (module-level — safe) ─────────────────────────────────────────
|
|
43
|
+
mock.module('@/lib/i18n', () => ({
|
|
44
|
+
t: (k: string) => k,
|
|
45
|
+
tWith: (k: string) => k,
|
|
46
|
+
resolveLocale: (v: unknown) =>
|
|
47
|
+
typeof v === 'string' ? v : ((v as Record<string, string>)?.en ?? ''),
|
|
48
|
+
useLanguage: () => ({ locale: 'en', setLocale: () => {} }),
|
|
49
|
+
}));
|
|
50
|
+
|
|
51
|
+
// ─── Component / layout stubs (module-level — safe) ──────────────────────────
|
|
52
|
+
const Noop = { default: () => null };
|
|
53
|
+
|
|
54
|
+
mock.module('@/components/PageHeader', () => Noop);
|
|
55
|
+
mock.module('@/components/FlowContent', () => Noop);
|
|
56
|
+
mock.module('@/components/FlowHubTabs', () => Noop);
|
|
57
|
+
mock.module('@/components/NoteContent', () => Noop);
|
|
58
|
+
mock.module('@/components/FlowCalendarSidebar', () => Noop);
|
|
59
|
+
mock.module('@/components/MarkdownRenderer', () => Noop);
|
|
60
|
+
mock.module('@/components/Backlinks', () => Noop);
|
|
61
|
+
mock.module('@/components/ShareBar', () => Noop);
|
|
62
|
+
mock.module('@/components/CoverImage', () => Noop);
|
|
63
|
+
mock.module('@/components/SeriesCatalog', () => Noop);
|
|
64
|
+
mock.module('@/components/Pagination', () => Noop);
|
|
65
|
+
mock.module('@/components/PostList', () => Noop);
|
|
66
|
+
mock.module('@/components/PostCard', () => Noop);
|
|
67
|
+
mock.module('@/components/TagPageHeader', () => Noop);
|
|
68
|
+
mock.module('@/components/TagSidebar', () => Noop);
|
|
69
|
+
mock.module('@/components/TagContentTabs', () => Noop);
|
|
70
|
+
mock.module('@/components/Tag', () => Noop);
|
|
71
|
+
mock.module('@/components/AuthorStats', () => Noop);
|
|
72
|
+
mock.module('@/components/TranslatedText', () => Noop);
|
|
73
|
+
mock.module('@/components/NoteSidebar', () => Noop);
|
|
74
|
+
mock.module('@/layouts/PostLayout', () => Noop);
|
|
75
|
+
mock.module('@/layouts/SimpleLayout', () => Noop);
|
|
76
|
+
mock.module('@/layouts/BookLayout', () => Noop);
|
|
77
|
+
|
|
78
|
+
// ─── Data layer stub: deferred to beforeAll ───────────────────────────────────
|
|
79
|
+
// Must NOT be called at module level — would replace @/lib/markdown in the
|
|
80
|
+
// shared registry before integration test files resolve their static imports.
|
|
81
|
+
beforeAll(() => {
|
|
82
|
+
mock.module('@/lib/markdown', () => ({
|
|
83
|
+
getAllFlows: () => [],
|
|
84
|
+
getAllNotes: () => [],
|
|
85
|
+
getAllPosts: () => [],
|
|
86
|
+
getAllBooks: () => [],
|
|
87
|
+
getAllSeries: () => ({}),
|
|
88
|
+
getAllTags: () => ({}),
|
|
89
|
+
getAllAuthors: () => ({}),
|
|
90
|
+
|
|
91
|
+
getFlowsByYear: () => [],
|
|
92
|
+
getFlowsByMonth: () => [],
|
|
93
|
+
getFlowBySlug: () => null,
|
|
94
|
+
getFlowTags: () => ({}),
|
|
95
|
+
getFlowsByTag: () => [],
|
|
96
|
+
|
|
97
|
+
getNoteBySlug: () => null,
|
|
98
|
+
getNoteTags: () => ({}),
|
|
99
|
+
getNotesByTag: () => [],
|
|
100
|
+
getAdjacentNotes: () => ({ prev: null, next: null }),
|
|
101
|
+
getRecentNotes: () => [],
|
|
102
|
+
|
|
103
|
+
getPostBySlug: () => null,
|
|
104
|
+
getRelatedPosts: () => [],
|
|
105
|
+
getAdjacentPosts: () => ({ prev: null, next: null }),
|
|
106
|
+
getPostsByTag: () => [],
|
|
107
|
+
getPostsByAuthor: () => [],
|
|
108
|
+
|
|
109
|
+
getBookData: () => null,
|
|
110
|
+
getBookChapter: () => null,
|
|
111
|
+
getBooksByAuthor: () => [],
|
|
112
|
+
|
|
113
|
+
getSeriesData: () => null,
|
|
114
|
+
getSeriesPosts: () => [],
|
|
115
|
+
getSeriesAuthors: () => [],
|
|
116
|
+
|
|
117
|
+
getAuthorSlug: (name: string) =>
|
|
118
|
+
name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''),
|
|
119
|
+
resolveAuthorParam: () => null,
|
|
120
|
+
|
|
121
|
+
getAdjacentFlows: () => ({ prev: null, next: null }),
|
|
122
|
+
buildSlugRegistry: () => new Map(),
|
|
123
|
+
getBacklinks: () => [],
|
|
124
|
+
}));
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// ─── Restore real markdown module ─────────────────────────────────────────────
|
|
128
|
+
afterAll(() => {
|
|
129
|
+
mock.module('@/lib/markdown', () => realMarkdown);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
describe('generateStaticParams — placeholder when content is empty', () => {
|
|
135
|
+
|
|
136
|
+
describe('flow routes', () => {
|
|
137
|
+
test('flows/[year] returns [{ year: "_" }]', async () => {
|
|
138
|
+
const { generateStaticParams } = await import('../../src/app/flows/[year]/page');
|
|
139
|
+
expect(generateStaticParams()).toEqual([{ year: '_' }]);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test('flows/[year]/[month] returns [{ year: "_", month: "_" }]', async () => {
|
|
143
|
+
const { generateStaticParams } = await import('../../src/app/flows/[year]/[month]/page');
|
|
144
|
+
expect(generateStaticParams()).toEqual([{ year: '_', month: '_' }]);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test('flows/[year]/[month]/[day] returns [{ year: "_", month: "_", day: "_" }]', async () => {
|
|
148
|
+
const { generateStaticParams } = await import('../../src/app/flows/[year]/[month]/[day]/page');
|
|
149
|
+
expect(generateStaticParams()).toEqual([{ year: '_', month: '_', day: '_' }]);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('flows/page/[page] always returns at least [{ page: "2" }]', async () => {
|
|
153
|
+
const { generateStaticParams } = await import('../../src/app/flows/page/[page]/page');
|
|
154
|
+
const params = generateStaticParams();
|
|
155
|
+
expect(params.length).toBeGreaterThanOrEqual(1);
|
|
156
|
+
expect(params[0]).toEqual({ page: '2' });
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
describe('notes routes', () => {
|
|
161
|
+
test('notes/[slug] returns [{ slug: "_" }]', async () => {
|
|
162
|
+
const { generateStaticParams } = await import('../../src/app/notes/[slug]/page');
|
|
163
|
+
expect(generateStaticParams()).toEqual([{ slug: '_' }]);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test('notes/page/[page] always returns at least [{ page: "2" }]', async () => {
|
|
167
|
+
const { generateStaticParams } = await import('../../src/app/notes/page/[page]/page');
|
|
168
|
+
const params = generateStaticParams();
|
|
169
|
+
expect(params.length).toBeGreaterThanOrEqual(1);
|
|
170
|
+
expect(params[0]).toEqual({ page: '2' });
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
describe('books routes', () => {
|
|
175
|
+
test('books/[slug] returns [{ slug: "_" }]', async () => {
|
|
176
|
+
const { generateStaticParams } = await import('../../src/app/books/[slug]/page');
|
|
177
|
+
const params = await generateStaticParams();
|
|
178
|
+
expect(params).toEqual([{ slug: '_' }]);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test('books/[slug]/[chapter] returns [{ slug: "_", chapter: "_" }]', async () => {
|
|
182
|
+
const { generateStaticParams } = await import('../../src/app/books/[slug]/[chapter]/page');
|
|
183
|
+
const params = await generateStaticParams();
|
|
184
|
+
expect(params).toEqual([{ slug: '_', chapter: '_' }]);
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
describe('series routes', () => {
|
|
189
|
+
test('series/[slug] returns [{ slug: "_" }]', async () => {
|
|
190
|
+
const { generateStaticParams } = await import('../../src/app/series/[slug]/page');
|
|
191
|
+
const params = await generateStaticParams();
|
|
192
|
+
expect(params).toEqual([{ slug: '_' }]);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test('series/[slug]/page/[page] returns [{ slug: "_", page: "2" }]', async () => {
|
|
196
|
+
const { generateStaticParams } = await import('../../src/app/series/[slug]/page/[page]/page');
|
|
197
|
+
const params = await generateStaticParams();
|
|
198
|
+
expect(params).toEqual([{ slug: '_', page: '2' }]);
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
describe('posts routes', () => {
|
|
203
|
+
test('posts/[slug] returns [{ slug: "_" }]', async () => {
|
|
204
|
+
const { generateStaticParams } = await import('../../src/app/posts/[slug]/page');
|
|
205
|
+
const params = await generateStaticParams();
|
|
206
|
+
expect(params).toEqual([{ slug: '_' }]);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test('posts/page/[page] returns [{ page: "2" }]', async () => {
|
|
210
|
+
const { generateStaticParams } = await import('../../src/app/posts/page/[page]/page');
|
|
211
|
+
const params = generateStaticParams();
|
|
212
|
+
expect(params).toEqual([{ page: '2' }]);
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
describe('taxonomy routes', () => {
|
|
217
|
+
test('tags/[tag] returns [{ tag: "_" }]', async () => {
|
|
218
|
+
const { generateStaticParams } = await import('../../src/app/tags/[tag]/page');
|
|
219
|
+
const params = await generateStaticParams();
|
|
220
|
+
expect(params).toEqual([{ tag: '_' }]);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test('authors/[author] returns [{ author: "_" }]', async () => {
|
|
224
|
+
const { generateStaticParams } = await import('../../src/app/authors/[author]/page');
|
|
225
|
+
const params = await generateStaticParams();
|
|
226
|
+
expect(params).toEqual([{ author: '_' }]);
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
describe('homepage pagination', () => {
|
|
231
|
+
test('page/[page] returns [{ page: "2" }]', async () => {
|
|
232
|
+
const { generateStaticParams } = await import('../../src/app/page/[page]/page');
|
|
233
|
+
const params = await generateStaticParams();
|
|
234
|
+
expect(params).toEqual([{ page: '2' }]);
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
});
|
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
title: "The Philosophy of Digital Gardening"
|
|
3
|
-
date: "2026-02-11"
|
|
4
|
-
category: "Thinking"
|
|
5
|
-
tags: ["philosophy", "digital-garden"]
|
|
6
|
-
authors: ["John Hu"]
|
|
7
|
-
featured: true
|
|
8
|
-
coverImage: "https://images.unsplash.com/photo-1585320806297-9794b3e4eeae?auto=format&fit=crop&w=1200&q=80"
|
|
9
|
-
---
|
|
10
|
-
|
|
11
|
-
Digital gardening is the act of cultivating a personal knowledge base in public. Unlike traditional blogs, which are linear and time-bound, a digital garden is non-linear and evolving.
|
|
12
|
-
|
|
13
|
-
## Why a Garden?
|
|
14
|
-
|
|
15
|
-
Traditional blogging is like a stream. Posts flow past you and disappear into the archive. A garden is like a network. Ideas are planted, linked, and grown over time.
|
|
16
|
-
|
|
17
|
-
### Core Principles
|
|
18
|
-
|
|
19
|
-
1. **Topography over Chronology**: Content is organized by topic and relationship, not just by date.
|
|
20
|
-
2. **Growth**: Notes start as "seedlings" and evolve into "evergreen" content.
|
|
21
|
-
3. **Public Learning**: Sharing the process of learning, not just the final result.
|
|
22
|
-
|
|
23
|
-
> "A garden is a grand teacher. It teaches patience and careful watchfulness; it teaches industry and thrift; above all it teaches entire trust." — Gertrude Jekyll
|