@hutusi/amytis 1.12.0 → 1.14.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 (78) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/GEMINI.md +9 -1
  3. package/README.md +26 -17
  4. package/README.zh.md +180 -100
  5. package/bun.lock +78 -74
  6. package/content/books/notes-on-thinking/cost-of-certainty.mdx +9 -0
  7. package/content/books/notes-on-thinking/index.mdx +16 -0
  8. package/content/books/notes-on-thinking/mental-models.mdx +9 -0
  9. package/content/books/the-pragmatic-writer/finding-your-voice.mdx +9 -0
  10. package/content/books/the-pragmatic-writer/index.mdx +18 -0
  11. package/content/books/the-pragmatic-writer/the-editing-loop.mdx +9 -0
  12. package/content/books/the-pragmatic-writer/why-writing-matters.mdx +9 -0
  13. package/content/flows/2026/03/01.md +9 -0
  14. package/content/flows/2026/03/03.md +9 -0
  15. package/content/flows/2026/03/05.md +10 -0
  16. package/content/flows/2026/03/07.md +11 -0
  17. package/content/posts/images/vibrant-waves.jpg +0 -0
  18. package/content/posts/welcome-to-amytis.mdx +3 -0
  19. package/content/series/markdown-showcase/index.mdx +2 -1
  20. package/content/series/markdown-showcase/mathematical-notation.mdx +8 -4
  21. package/content/series/markdown-showcase/syntax-highlighting.mdx +9 -5
  22. package/content/series/markdown-showcase/visuals-and-diagrams.mdx +8 -4
  23. package/content/{posts → series/markdown-showcase}//344/270/255/346/226/207/346/265/213/350/257/225/346/226/207/347/253/240.mdx +12 -7
  24. package/content/series/modern-web-dev/index.mdx +4 -2
  25. package/docs/ARCHITECTURE.md +8 -1
  26. package/docs/DIGITAL_GARDEN.md +22 -1
  27. package/package.json +12 -12
  28. package/public/next-image-export-optimizer-hashes.json +3 -2
  29. package/scripts/new-flow.ts +1 -0
  30. package/site.config.example.ts +3 -4
  31. package/site.config.ts +6 -7
  32. package/src/app/[slug]/[postSlug]/page.tsx +19 -2
  33. package/src/app/[slug]/page/[page]/page.tsx +26 -5
  34. package/src/app/[slug]/page.tsx +28 -8
  35. package/src/app/all.atom/route.ts +7 -0
  36. package/src/app/all.xml/route.ts +7 -0
  37. package/src/app/archive/page.tsx +7 -4
  38. package/src/app/feed.atom/route.ts +2 -57
  39. package/src/app/feed.xml/route.ts +2 -64
  40. package/src/app/flows/[year]/[month]/[day]/page.tsx +13 -0
  41. package/src/app/flows/feed.atom/route.ts +7 -0
  42. package/src/app/flows/feed.xml/route.ts +7 -0
  43. package/src/app/page.tsx +1 -2
  44. package/src/app/posts/[slug]/page.tsx +28 -9
  45. package/src/app/posts/feed.atom/route.ts +9 -0
  46. package/src/app/posts/feed.xml/route.ts +9 -0
  47. package/src/app/series/[slug]/page.tsx +46 -4
  48. package/src/components/CuratedSeriesSection.tsx +7 -11
  49. package/src/components/FeaturedStoriesSection.tsx +1 -1
  50. package/src/components/FlowCalendarSidebar.tsx +1 -1
  51. package/src/components/FlowContent.tsx +2 -1
  52. package/src/components/FlowTimelineEntry.tsx +7 -1
  53. package/src/components/Footer.tsx +6 -6
  54. package/src/components/HorizontalScroll.tsx +5 -14
  55. package/src/components/MarkdownRenderer.test.tsx +6 -0
  56. package/src/components/MarkdownRenderer.tsx +18 -16
  57. package/src/components/Navbar.tsx +1 -1
  58. package/src/components/PostList.tsx +20 -36
  59. package/src/components/PostSidebar.tsx +1 -1
  60. package/src/components/RecentNotesSection.tsx +4 -0
  61. package/src/components/SelectedBooksSection.tsx +65 -25
  62. package/src/components/SeriesCatalog.tsx +9 -7
  63. package/src/i18n/translations.ts +2 -0
  64. package/src/layouts/PostLayout.tsx +1 -1
  65. package/src/layouts/SimpleLayout.tsx +3 -3
  66. package/src/lib/feed-utils.ts +158 -18
  67. package/src/lib/markdown.ts +26 -5
  68. package/src/lib/urls.ts +9 -4
  69. package/tests/e2e/mobile/mobile-compat.spec.ts +58 -0
  70. package/tests/e2e/navigation.test.ts +26 -0
  71. package/tests/integration/collections.test.ts +17 -2
  72. package/tests/integration/feed-utils.test.ts +52 -0
  73. package/tests/integration/flow-title.test.ts +53 -0
  74. package/tests/integration/markdown-features.test.ts +3 -3
  75. package/tests/integration/reading-time-headings.test.ts +2 -2
  76. package/tests/unit/static-params.test.ts +155 -22
  77. package/tests/unit/urls.test.ts +10 -12
  78. /package/content/posts/{multilingual-test.mdx → multilingual-test-/344/270/255/346/226/207/351/225/277/346/240/207/351/242/230.mdx"} +0 -0
@@ -22,14 +22,24 @@
22
22
  */
23
23
  import { describe, test, expect, mock, beforeAll, beforeEach, afterAll, afterEach } from 'bun:test';
24
24
 
25
- // ─── Capture real markdown module ────────────────────────────────────────────
25
+ // ─── Capture real modules ─────────────────────────────────────────────────────
26
26
  // Static imports are hoisted and resolved before any executable code (including
27
27
  // beforeAll / mock.module calls), so this always captures the real module.
28
28
  import * as realMarkdown from '../../src/lib/markdown';
29
+ import * as realUrls from '../../src/lib/urls';
30
+
31
+ // `import * as ns` creates a live namespace — its properties update when
32
+ // mock.module() patches the registry. Spread into a plain object here
33
+ // (before any mocking) to get a shallow snapshot of the real exports so
34
+ // restore calls always put back the originals, not the current mock state.
35
+ // Note: nested objects (e.g. RESERVED_ROUTE_SEGMENTS Set) share the same
36
+ // reference, but they are never mutated during tests so this is safe.
37
+ const snapshotUrls = { ...realUrls };
29
38
 
30
39
  let mockedPosts: Array<{ slug: string; series?: string; redirectFrom?: string[] }> = [];
31
40
  let mockedNotes: Array<{ slug: string }> = [];
32
41
  let mockedSeries: Record<string, Array<{ slug: string }>> = {};
42
+ let mockedSeriesData: Record<string, { redirectFrom?: string[]; title?: string }> = {};
33
43
  const originalNodeEnv = process.env.NODE_ENV;
34
44
 
35
45
  // ─── Next.js runtime stubs (module-level — safe) ─────────────────────────────
@@ -118,7 +128,7 @@ beforeAll(() => {
118
128
  getBookChapter: () => null,
119
129
  getBooksByAuthor: () => [],
120
130
 
121
- getSeriesData: () => null,
131
+ getSeriesData: (slug: string) => mockedSeriesData[slug] ?? null,
122
132
  getSeriesPosts: () => [],
123
133
  getSeriesAuthors: () => [],
124
134
 
@@ -136,6 +146,7 @@ beforeEach(() => {
136
146
  mockedPosts = [];
137
147
  mockedNotes = [];
138
148
  mockedSeries = {};
149
+ mockedSeriesData = {};
139
150
  process.env.NODE_ENV = originalNodeEnv;
140
151
  });
141
152
 
@@ -143,12 +154,14 @@ afterEach(() => {
143
154
  mockedPosts = [];
144
155
  mockedNotes = [];
145
156
  mockedSeries = {};
157
+ mockedSeriesData = {};
146
158
  process.env.NODE_ENV = originalNodeEnv;
147
159
  });
148
160
 
149
- // ─── Restore real markdown module ─────────────────────────────────────────────
161
+ // ─── Restore real modules ─────────────────────────────────────────────────────
150
162
  afterAll(() => {
151
163
  mock.module('@/lib/markdown', () => realMarkdown);
164
+ mock.module('@/lib/urls', () => snapshotUrls);
152
165
  });
153
166
 
154
167
  // ─────────────────────────────────────────────────────────────────────────────
@@ -232,6 +245,15 @@ describe('generateStaticParams — placeholder when content is empty', () => {
232
245
  expect(params).toEqual([{ slug: '_' }]);
233
246
  });
234
247
 
248
+ test('series/[slug] includes redirectFrom slug when series is renamed', async () => {
249
+ mockedSeries = { 'new-name': [] };
250
+ mockedSeriesData = { 'new-name': { redirectFrom: ['/series/old-name'], title: 'New Series' } };
251
+ const { generateStaticParams } = await import('../../src/app/series/[slug]/page');
252
+ const params = await generateStaticParams();
253
+ expect(params).toContainEqual({ slug: 'new-name' });
254
+ expect(params).toContainEqual({ slug: 'old-name' });
255
+ });
256
+
235
257
  test('series/[slug]/page/[page] returns [{ slug: "_", page: "2" }]', async () => {
236
258
  const { generateStaticParams } = await import('../../src/app/series/[slug]/page/[page]/page');
237
259
  const params = await generateStaticParams();
@@ -296,35 +318,64 @@ describe('generateStaticParams — placeholder when content is empty', () => {
296
318
  });
297
319
 
298
320
  describe('autoPaths series routing', () => {
299
- // autoPaths defaults to false — series posts are served at /posts/[slug] unless explicitly enabled
300
-
301
- test('posts/[slug] includes series posts when autoPaths is disabled (default)', async () => {
302
- mockedPosts = [{ slug: 'series-post', series: 'my-series' }];
303
- const { generateStaticParams } = await import('../../src/app/posts/[slug]/page');
304
- const params = await generateStaticParams();
305
- expect(params).toContainEqual({ slug: 'series-post' });
321
+ // autoPaths defaults to true — series posts are served at /[series-slug]/[post-slug]
322
+
323
+ describe('autoPaths disabled', () => {
324
+ // Override getSeriesAutoPaths to false and use /posts/[slug] as canonical URL.
325
+ beforeEach(() => {
326
+ mock.module('@/lib/urls', () => ({
327
+ ...snapshotUrls,
328
+ getSeriesAutoPaths: () => false,
329
+ getPostUrl: (post: { slug: string; series?: string }) => `/posts/${post.slug}`,
330
+ }));
331
+ });
332
+
333
+ afterEach(() => {
334
+ mock.module('@/lib/urls', () => snapshotUrls);
335
+ });
336
+
337
+ test('posts/[slug] includes series posts when autoPaths is disabled', async () => {
338
+ mockedPosts = [{ slug: 'series-post', series: 'my-series' }];
339
+ const { generateStaticParams } = await import('../../src/app/posts/[slug]/page');
340
+ const params = await generateStaticParams();
341
+ expect(params).toContainEqual({ slug: 'series-post' });
342
+ });
343
+
344
+ test('[slug]/[postSlug] does not include series auto-path params when autoPaths is disabled', async () => {
345
+ mockedSeries = { 'my-series': [{ slug: 'my-post' }] };
346
+ const { generateStaticParams } = await import('../../src/app/[slug]/[postSlug]/page');
347
+ const params = await generateStaticParams();
348
+ expect(params).not.toContainEqual({ slug: 'my-series', postSlug: 'my-post' });
349
+ });
350
+
351
+ test('posts/[slug] includes series post when canonical matches /posts/[slug]', async () => {
352
+ mockedPosts = [{ slug: 'my-post', series: 'my-series' }];
353
+ const { generateStaticParams } = await import('../../src/app/posts/[slug]/page');
354
+ const params = await generateStaticParams();
355
+ expect(params).toContainEqual({ slug: 'my-post' });
356
+ });
306
357
  });
307
358
 
308
- test('[slug]/[postSlug] does not include series auto-path params when autoPaths is disabled', async () => {
309
- mockedSeries = { 'my-series': [{ slug: 'my-post' }] };
359
+ test('[slug]/[postSlug] includes redirectFrom paths as additional params', async () => {
360
+ mockedPosts = [{ slug: 'my-post', series: 'my-series', redirectFrom: ['/old-prefix/my-post'] }];
310
361
  const { generateStaticParams } = await import('../../src/app/[slug]/[postSlug]/page');
311
362
  const params = await generateStaticParams();
312
- expect(params).not.toContainEqual({ slug: 'my-series', postSlug: 'my-post' });
363
+ expect(params).toContainEqual({ slug: 'old-prefix', postSlug: 'my-post' });
313
364
  });
314
365
 
315
- test('posts/[slug] includes series post when canonical matches /posts/[slug]', async () => {
316
- // With autoPaths: false, getPostUrl returns /posts/[slug] for series posts
317
- mockedPosts = [{ slug: 'my-post', series: 'my-series' }];
318
- const { generateStaticParams } = await import('../../src/app/posts/[slug]/page');
366
+ test('[slug]/[postSlug] does not include /posts/* redirectFrom when basePath is "posts"', async () => {
367
+ mockedPosts = [{ slug: 'new-name', redirectFrom: ['/posts/old-name'] }];
368
+ const { generateStaticParams } = await import('../../src/app/[slug]/[postSlug]/page');
319
369
  const params = await generateStaticParams();
320
- expect(params).toContainEqual({ slug: 'my-post' });
370
+ expect(params).not.toContainEqual({ slug: 'posts', postSlug: 'old-name' });
321
371
  });
322
372
 
323
- test('[slug]/[postSlug] includes redirectFrom paths as additional params', async () => {
324
- mockedPosts = [{ slug: 'my-post', series: 'my-series', redirectFrom: ['/old-prefix/my-post'] }];
325
- const { generateStaticParams } = await import('../../src/app/[slug]/[postSlug]/page');
373
+ test('posts/[slug] includes redirectFrom slug when post is renamed within /posts/', async () => {
374
+ mockedPosts = [{ slug: 'new-name', redirectFrom: ['/posts/old-name'] }];
375
+ const { generateStaticParams } = await import('../../src/app/posts/[slug]/page');
326
376
  const params = await generateStaticParams();
327
- expect(params).toContainEqual({ slug: 'old-prefix', postSlug: 'my-post' });
377
+ expect(params).toContainEqual({ slug: 'new-name' });
378
+ expect(params).toContainEqual({ slug: 'old-name' });
328
379
  });
329
380
 
330
381
  test('[slug]/page includes single-segment redirectFrom paths as additional params', async () => {
@@ -363,6 +414,69 @@ describe('generateStaticParams — placeholder when content is empty', () => {
363
414
  const { generateStaticParams } = await import('../../src/app/[slug]/page');
364
415
  expect(() => generateStaticParams()).toThrow('[amytis] redirectFrom "/old-slug"');
365
416
  });
417
+
418
+ test('[slug]/page throws when redirectFrom alias conflicts with "posts" (RESERVED_ROUTE_SEGMENTS)', async () => {
419
+ mockedPosts = [{ slug: 'my-post', redirectFrom: ['/posts'] }];
420
+ const { generateStaticParams } = await import('../../src/app/[slug]/page');
421
+ expect(() => generateStaticParams()).toThrow('[amytis] redirectFrom "/posts"');
422
+ });
423
+
424
+ test('[slug]/page includes Unicode single-segment redirectFrom slug as param', async () => {
425
+ mockedPosts = [{ slug: 'my-post', redirectFrom: ['/中文路由'] }];
426
+ const { generateStaticParams } = await import('../../src/app/[slug]/page');
427
+ const params = await generateStaticParams();
428
+ expect(params).toContainEqual({ slug: '中文路由' });
429
+ });
430
+
431
+ describe('autoPaths enabled', () => {
432
+ // Use beforeEach (not beforeAll) so each test starts with a clean base mock,
433
+ // preventing stale state from tests that inline-override the mock.
434
+ beforeEach(() => {
435
+ mock.module('@/lib/urls', () => ({
436
+ ...snapshotUrls,
437
+ getSeriesAutoPaths: () => true,
438
+ getSeriesCustomPaths: () => ({}),
439
+ getPostUrl: (post: { slug: string; series?: string }) =>
440
+ post.series ? `/${post.series}/${post.slug}` : `/posts/${post.slug}`,
441
+ validateSeriesAutoPaths: () => {},
442
+ }));
443
+ });
444
+
445
+ afterEach(() => {
446
+ mock.module('@/lib/urls', () => snapshotUrls);
447
+ });
448
+
449
+ test('[slug]/page includes auto-path series slug when autoPaths enabled', async () => {
450
+ mockedSeries = { 'my-series': [{ slug: 'my-post' }] };
451
+ const { generateStaticParams } = await import('../../src/app/[slug]/page');
452
+ const params = await generateStaticParams();
453
+ expect(params).toContainEqual({ slug: 'my-series' });
454
+ });
455
+
456
+ test('[slug]/page includes Unicode auto-path series slug when autoPaths enabled', async () => {
457
+ mockedSeries = { '中文系列': [{ slug: 'post-one' }] };
458
+ const { generateStaticParams } = await import('../../src/app/[slug]/page');
459
+ const params = await generateStaticParams();
460
+ expect(params).toContainEqual({ slug: '中文系列' });
461
+ });
462
+
463
+ test('[slug]/page uses customPaths prefix for series with override, not the series slug', async () => {
464
+ // Override the base mock to add a customPaths entry for this test.
465
+ mock.module('@/lib/urls', () => ({
466
+ ...snapshotUrls,
467
+ getSeriesAutoPaths: () => true,
468
+ getSeriesCustomPaths: () => ({ 'my-series': 'articles' }),
469
+ getPostUrl: (post: { slug: string; series?: string }) =>
470
+ post.series === 'my-series' ? `/articles/${post.slug}` : `/posts/${post.slug}`,
471
+ validateSeriesAutoPaths: () => {},
472
+ }));
473
+ mockedSeries = { 'my-series': [{ slug: 'my-post' }] };
474
+ const { generateStaticParams } = await import('../../src/app/[slug]/page');
475
+ const params = await generateStaticParams();
476
+ expect(params).toContainEqual({ slug: 'articles' });
477
+ expect(params).not.toContainEqual({ slug: 'my-series' });
478
+ });
479
+ });
366
480
  });
367
481
 
368
482
  describe('custom path routes', () => {
@@ -380,6 +494,25 @@ describe('generateStaticParams — placeholder when content is empty', () => {
380
494
  expect(params).toEqual([{ slug: '_', postSlug: '_' }]);
381
495
  });
382
496
 
497
+ test('[slug]/[postSlug] includes encoded Unicode postSlug variants in non-production', async () => {
498
+ // Use redirectFrom to place a Unicode postSlug at a 2-segment path — no url mock needed.
499
+ mockedPosts = [{ slug: 'my-post', redirectFrom: ['/old-prefix/中文文章'] }];
500
+ process.env.NODE_ENV = 'development';
501
+ const { generateStaticParams } = await import('../../src/app/[slug]/[postSlug]/page');
502
+ const params = await generateStaticParams();
503
+ expect(params).toContainEqual({ slug: 'old-prefix', postSlug: '中文文章' });
504
+ expect(params).toContainEqual({ slug: 'old-prefix', postSlug: encodeURIComponent('中文文章') });
505
+ });
506
+
507
+ test('[slug]/[postSlug] does not include encoded Unicode postSlug variants in production', async () => {
508
+ mockedPosts = [{ slug: 'my-post', redirectFrom: ['/old-prefix/中文文章'] }];
509
+ process.env.NODE_ENV = 'production';
510
+ const { generateStaticParams } = await import('../../src/app/[slug]/[postSlug]/page');
511
+ const params = await generateStaticParams();
512
+ expect(params).toContainEqual({ slug: 'old-prefix', postSlug: '中文文章' });
513
+ expect(params).not.toContainEqual({ slug: 'old-prefix', postSlug: encodeURIComponent('中文文章') });
514
+ });
515
+
383
516
  test('[slug]/page/[page]/page returns placeholder when no custom paths', async () => {
384
517
  const { generateStaticParams } = await import('../../src/app/[slug]/page/[page]/page');
385
518
  const params = await generateStaticParams();
@@ -2,29 +2,27 @@ import { describe, test, expect } from 'bun:test';
2
2
  import { getPostUrl, getSeriesAutoPaths, validateSeriesAutoPaths } from '../../src/lib/urls';
3
3
 
4
4
  describe('getSeriesAutoPaths', () => {
5
- test('returns false by default', () => {
6
- expect(getSeriesAutoPaths()).toBe(false);
5
+ test('returns true by default', () => {
6
+ expect(getSeriesAutoPaths()).toBe(true);
7
7
  });
8
8
  });
9
9
 
10
- describe('getPostUrl — autoPaths disabled (default)', () => {
10
+ describe('getPostUrl — autoPaths enabled (default)', () => {
11
11
  test('post with no series uses basePath', () => {
12
12
  expect(getPostUrl({ slug: 'hello' })).toBe('/posts/hello');
13
13
  });
14
14
 
15
- test('post with series falls back to basePath when autoPaths is disabled', () => {
16
- expect(getPostUrl({ slug: 'hello', series: 'my-series' })).toBe('/posts/hello');
15
+ test('post with series uses series slug as prefix when autoPaths is enabled', () => {
16
+ expect(getPostUrl({ slug: 'hello', series: 'my-series' })).toBe('/my-series/hello');
17
17
  });
18
18
  });
19
19
 
20
- describe('validateSeriesAutoPaths — autoPaths disabled (default)', () => {
21
- test('does not throw for any slug when autoPaths is false', () => {
22
- // validateSeriesAutoPaths is a no-op when autoPaths is disabled
23
- const reserved = ['tags', 'series', 'books', 'flows', 'archive', 'posts'];
24
- expect(() => validateSeriesAutoPaths(reserved)).not.toThrow();
20
+ describe('validateSeriesAutoPaths — autoPaths enabled (default)', () => {
21
+ test('throws for a reserved route slug', () => {
22
+ expect(() => validateSeriesAutoPaths(['tags'])).toThrow('[amytis]');
25
23
  });
26
24
 
27
- test('does not throw even with extraReserved slugs when autoPaths is false', () => {
28
- expect(() => validateSeriesAutoPaths(['about'], ['about'])).not.toThrow();
25
+ test('throws for an extra reserved slug', () => {
26
+ expect(() => validateSeriesAutoPaths(['about'], ['about'])).toThrow('[amytis]');
29
27
  });
30
28
  });