@hutusi/amytis 1.15.0 → 1.17.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 (120) hide show
  1. package/.claude/rules/immersive-reading.md +21 -0
  2. package/.claude/rules/rst.md +13 -0
  3. package/CHANGELOG.md +42 -0
  4. package/CLAUDE.md +89 -219
  5. package/bun.lock +185 -547
  6. package/content/books/sample-book/index.mdx +3 -0
  7. package/content/posts/code-block-features-showcase.mdx +223 -0
  8. package/docs/ALERTS.md +112 -0
  9. package/docs/ARCHITECTURE.md +298 -5
  10. package/docs/CODE-BLOCKS.md +238 -0
  11. package/docs/CONTRIBUTING.md +25 -0
  12. package/docs/DIGITAL_GARDEN.md +1 -1
  13. package/docs/guides/README.md +11 -0
  14. package/docs/guides/importing-vuepress-books.md +237 -0
  15. package/eslint.config.mjs +18 -6
  16. package/package.json +42 -20
  17. package/scripts/generate-code-group-icons.ts +79 -0
  18. package/scripts/render-rst.py +207 -3
  19. package/scripts/sync-vuepress-book.ts +710 -0
  20. package/site.config.example.ts +3 -3
  21. package/site.config.ts +3 -3
  22. package/src/app/[slug]/layout.tsx +30 -0
  23. package/src/app/books/[slug]/{[chapter] → [...chapter]}/page.tsx +32 -10
  24. package/src/app/books/[slug]/layout.tsx +24 -0
  25. package/src/app/books/[slug]/page.tsx +85 -34
  26. package/src/app/globals.css +570 -123
  27. package/src/app/page.tsx +7 -1
  28. package/src/app/posts/layout.tsx +20 -0
  29. package/src/app/series/[slug]/page.tsx +33 -9
  30. package/src/app/sitemap.ts +3 -3
  31. package/src/components/ArticleCopyCleaner.tsx +64 -0
  32. package/src/components/BookMobileNav.tsx +44 -50
  33. package/src/components/BookReadingShell.tsx +145 -0
  34. package/src/components/BookSidebar.tsx +0 -0
  35. package/src/components/CodeBlock.test.tsx +93 -8
  36. package/src/components/CodeBlock.tsx +39 -101
  37. package/src/components/CodeBlockToolbar.tsx +88 -0
  38. package/src/components/CodeGroup.tsx +81 -0
  39. package/src/components/CoverImage.tsx +1 -0
  40. package/src/components/CuratedSeriesSection.tsx +28 -10
  41. package/src/components/ExternalLinkIcon.tsx +15 -0
  42. package/src/components/FeaturedStoriesSection.tsx +44 -23
  43. package/src/components/Footer.tsx +1 -1
  44. package/src/components/GithubAlert.tsx +97 -0
  45. package/src/components/ImmersiveReader.tsx +130 -0
  46. package/src/components/ImmersiveReaderTopBar.tsx +106 -0
  47. package/src/components/ImmersiveReadingFlagHandler.tsx +40 -0
  48. package/src/components/ImmersiveReadingPrefsPopover.tsx +249 -0
  49. package/src/components/ImmersiveReadingProvider.tsx +168 -0
  50. package/src/components/ImmersiveSeriesSidebar.tsx +143 -0
  51. package/src/components/ImmersiveToggleButton.tsx +45 -0
  52. package/src/components/MarkdownRenderer.test.tsx +14 -4
  53. package/src/components/MarkdownRenderer.tsx +175 -23
  54. package/src/components/Mermaid.tsx +32 -1
  55. package/src/components/Navbar.tsx +3 -1
  56. package/src/components/PostList.tsx +1 -1
  57. package/src/components/PostNavigation.tsx +13 -2
  58. package/src/components/PostReadingShell.tsx +68 -0
  59. package/src/components/PostSidebar.tsx +13 -2
  60. package/src/components/ReadingProgressBar.tsx +1 -1
  61. package/src/components/RstRenderer.test.tsx +15 -15
  62. package/src/components/RstRenderer.tsx +37 -2
  63. package/src/components/Search.tsx +18 -4
  64. package/src/components/SelectedBooksSection.tsx +27 -8
  65. package/src/components/SeriesCatalog.tsx +1 -1
  66. package/src/components/ShareBar.tsx +5 -0
  67. package/src/components/TocPanel.tsx +10 -2
  68. package/src/hooks/useActiveHeading.ts +35 -13
  69. package/src/hooks/useSidebarAutoScroll.ts +31 -7
  70. package/src/i18n/translations.ts +44 -0
  71. package/src/layouts/BookLayout.tsx +62 -74
  72. package/src/layouts/PostLayout.tsx +154 -111
  73. package/src/lib/code-group-icons.test.ts +78 -0
  74. package/src/lib/code-group-icons.ts +148 -0
  75. package/src/lib/immersive-reading-prefs.ts +104 -0
  76. package/src/lib/markdown.test.ts +56 -13
  77. package/src/lib/markdown.ts +217 -57
  78. package/src/lib/normalize-vuepress-math.ts +118 -0
  79. package/src/lib/rehype-fence-meta.ts +22 -0
  80. package/src/lib/remark-book-chapter-links.ts +106 -0
  81. package/src/lib/remark-code-group.ts +54 -0
  82. package/src/lib/remark-github-alerts.test.ts +83 -0
  83. package/src/lib/remark-github-alerts.ts +65 -0
  84. package/src/lib/remark-vuepress-containers.ts +130 -0
  85. package/src/lib/rst-renderer.ts +19 -7
  86. package/src/lib/rst.test.ts +212 -2
  87. package/src/lib/rst.ts +217 -13
  88. package/src/lib/scroll-utils.ts +44 -6
  89. package/src/lib/shiki-rst.ts +185 -0
  90. package/src/lib/shiki.test.ts +153 -0
  91. package/src/lib/shiki.ts +292 -0
  92. package/src/lib/shuffle.ts +15 -1
  93. package/src/lib/sort.ts +15 -0
  94. package/src/lib/urls.ts +62 -0
  95. package/src/test-utils/render.ts +23 -0
  96. package/tests/fixtures/sync-vuepress-book/docs/.vuepress/config.js +43 -0
  97. package/tests/fixtures/sync-vuepress-book/docs/intro/welcome.md +7 -0
  98. package/tests/fixtures/sync-vuepress-book/docs/maths/linear/assets/diagram.png +1 -0
  99. package/tests/fixtures/sync-vuepress-book/docs/maths/linear/matrices.md +7 -0
  100. package/tests/fixtures/sync-vuepress-book/docs/maths/linear/vectors.md +9 -0
  101. package/tests/helpers/env.ts +19 -0
  102. package/tests/integration/book-chapter-links.test.ts +107 -0
  103. package/tests/integration/book-index-cta.test.ts +87 -0
  104. package/tests/integration/books-nested-toc.test.ts +176 -0
  105. package/tests/integration/books.test.ts +3 -2
  106. package/tests/integration/code-block-features.test.ts +188 -0
  107. package/tests/integration/code-group.test.ts +183 -0
  108. package/tests/integration/code-notation.test.ts +97 -0
  109. package/tests/integration/github-alerts.test.ts +82 -0
  110. package/tests/integration/markdown-external-links.test.ts +103 -0
  111. package/tests/integration/normalize-vuepress-math.test.ts +149 -0
  112. package/tests/integration/reading-time-headings.test.ts +8 -6
  113. package/tests/integration/series-draft.test.ts +6 -13
  114. package/tests/integration/series-index-cta.test.ts +88 -0
  115. package/tests/integration/sync-vuepress-book.test.ts +443 -0
  116. package/tests/integration/vuepress-containers.test.ts +107 -0
  117. package/tests/tooling/new-post.test.ts +1 -1
  118. package/tests/unit/immersive-reading-prefs.test.ts +144 -0
  119. package/tests/unit/static-params.test.ts +32 -19
  120. package/vercel.json +7 -0
@@ -0,0 +1,144 @@
1
+ import { describe, test, expect } from 'bun:test';
2
+ import {
3
+ DEFAULT_PREFS,
4
+ STORAGE_KEY,
5
+ readStoredPrefs,
6
+ writeStoredPrefs,
7
+ type StoredPrefs,
8
+ } from '../../src/lib/immersive-reading-prefs';
9
+
10
+ // Minimal in-memory mock matching the Storage interface subsets the helpers
11
+ // accept. Per-test instance keeps state isolated and avoids touching
12
+ // globalThis.localStorage. Optional setItem override lets us simulate
13
+ // private-browsing / quota-exceeded throws.
14
+ function makeMockStorage(
15
+ initial?: Record<string, string>,
16
+ opts: { setItemThrows?: boolean } = {},
17
+ ) {
18
+ const store: Record<string, string> = { ...(initial ?? {}) };
19
+ return {
20
+ getItem(key: string): string | null {
21
+ return Object.prototype.hasOwnProperty.call(store, key) ? store[key] : null;
22
+ },
23
+ setItem(key: string, value: string): void {
24
+ if (opts.setItemThrows) throw new Error('quota exceeded');
25
+ store[key] = value;
26
+ },
27
+ _store: store,
28
+ };
29
+ }
30
+
31
+ describe('readStoredPrefs', () => {
32
+ test('returns defaults when storage is empty', () => {
33
+ expect(readStoredPrefs(makeMockStorage())).toEqual(DEFAULT_PREFS);
34
+ });
35
+
36
+ test('returns defaults when no storage is available', () => {
37
+ // Passing a storage whose getItem always returns null mirrors the
38
+ // "globalThis.localStorage missing" path. Production callers can also
39
+ // pass undefined and rely on the lazy default — that path is exercised
40
+ // by the provider in the browser, not by unit tests.
41
+ const empty = { getItem: () => null };
42
+ expect(readStoredPrefs(empty)).toEqual(DEFAULT_PREFS);
43
+ });
44
+
45
+ test('returns defaults when stored JSON is invalid', () => {
46
+ expect(readStoredPrefs(makeMockStorage({ [STORAGE_KEY]: 'not-json{' }))).toEqual(DEFAULT_PREFS);
47
+ });
48
+
49
+ test('returns defaults when stored value is not an object', () => {
50
+ expect(readStoredPrefs(makeMockStorage({ [STORAGE_KEY]: '"a string"' }))).toEqual(DEFAULT_PREFS);
51
+ expect(readStoredPrefs(makeMockStorage({ [STORAGE_KEY]: 'null' }))).toEqual(DEFAULT_PREFS);
52
+ expect(readStoredPrefs(makeMockStorage({ [STORAGE_KEY]: '42' }))).toEqual(DEFAULT_PREFS);
53
+ });
54
+
55
+ test('returns defaults when storage entry is missing for the key', () => {
56
+ expect(readStoredPrefs(makeMockStorage({ 'unrelated-key': 'whatever' }))).toEqual(DEFAULT_PREFS);
57
+ });
58
+
59
+ test('round-trips a fully valid prefs blob', () => {
60
+ const blob: StoredPrefs = {
61
+ fontSize: 'xl',
62
+ readingTheme: 'sepia',
63
+ columnWidth: 'narrow',
64
+ sidebarOpen: false,
65
+ };
66
+ expect(readStoredPrefs(makeMockStorage({ [STORAGE_KEY]: JSON.stringify(blob) }))).toEqual(blob);
67
+ });
68
+
69
+ // The schema-drift case the helpers exist for: one corrupt key must not
70
+ // discard the whole blob — other valid keys still apply, the bad one
71
+ // falls back to its default.
72
+ test('per-key fallback: bad fontSize, others survive', () => {
73
+ const stored = JSON.stringify({
74
+ fontSize: 'banana',
75
+ readingTheme: 'dark',
76
+ columnWidth: 'full',
77
+ sidebarOpen: false,
78
+ });
79
+ expect(readStoredPrefs(makeMockStorage({ [STORAGE_KEY]: stored }))).toEqual({
80
+ fontSize: DEFAULT_PREFS.fontSize, // fell back
81
+ readingTheme: 'dark',
82
+ columnWidth: 'full',
83
+ sidebarOpen: false,
84
+ });
85
+ });
86
+
87
+ test('per-key fallback: bad readingTheme + columnWidth, others survive', () => {
88
+ const stored = JSON.stringify({
89
+ fontSize: 's',
90
+ readingTheme: 'neon',
91
+ columnWidth: 'ultra-wide',
92
+ sidebarOpen: true,
93
+ });
94
+ expect(readStoredPrefs(makeMockStorage({ [STORAGE_KEY]: stored }))).toEqual({
95
+ fontSize: 's',
96
+ readingTheme: DEFAULT_PREFS.readingTheme,
97
+ columnWidth: DEFAULT_PREFS.columnWidth,
98
+ sidebarOpen: true,
99
+ });
100
+ });
101
+
102
+ test('sidebarOpen is strict-boolean — string "true" does not count', () => {
103
+ const stored = JSON.stringify({
104
+ fontSize: 'm',
105
+ readingTheme: 'auto',
106
+ columnWidth: 'wide',
107
+ sidebarOpen: 'true',
108
+ });
109
+ expect(readStoredPrefs(makeMockStorage({ [STORAGE_KEY]: stored })).sidebarOpen).toBe(
110
+ DEFAULT_PREFS.sidebarOpen,
111
+ );
112
+ });
113
+
114
+ test('returns defaults when all keys are missing from a valid object', () => {
115
+ expect(readStoredPrefs(makeMockStorage({ [STORAGE_KEY]: '{}' }))).toEqual(DEFAULT_PREFS);
116
+ });
117
+ });
118
+
119
+ describe('writeStoredPrefs', () => {
120
+ test('writes the full blob under STORAGE_KEY as JSON', () => {
121
+ const storage = makeMockStorage();
122
+ const blob: StoredPrefs = {
123
+ fontSize: 'l',
124
+ readingTheme: 'dark',
125
+ columnWidth: 'medium',
126
+ sidebarOpen: false,
127
+ };
128
+ writeStoredPrefs(blob, storage);
129
+ expect(JSON.parse(storage._store[STORAGE_KEY])).toEqual(blob);
130
+ });
131
+
132
+ test('swallows throws (private browsing / quota exceeded)', () => {
133
+ const storage = makeMockStorage(undefined, { setItemThrows: true });
134
+ // Must not crash — the production caller relies on this silence so the
135
+ // reader still works in private browsing.
136
+ expect(() => writeStoredPrefs(DEFAULT_PREFS, storage)).not.toThrow();
137
+ });
138
+
139
+ test('does nothing when no storage is available', () => {
140
+ // Same as above but exercising the no-storage path. The "no storage"
141
+ // call site in production happens during SSR.
142
+ expect(() => writeStoredPrefs(DEFAULT_PREFS, undefined)).not.toThrow();
143
+ });
144
+ });
@@ -21,6 +21,7 @@
21
21
  * • afterAll restores the real module so any subsequent tests see real data.
22
22
  */
23
23
  import { describe, test, expect, mock, beforeAll, beforeEach, afterAll, afterEach } from 'bun:test';
24
+ import { setEnvVar, restoreEnvVar } from '../helpers/env';
24
25
 
25
26
  // ─── Capture real modules ─────────────────────────────────────────────────────
26
27
  // Static imports are hoisted and resolved before any executable code (including
@@ -36,7 +37,18 @@ import * as realUrls from '../../src/lib/urls';
36
37
  // reference, but they are never mutated during tests so this is safe.
37
38
  const snapshotUrls = { ...realUrls };
38
39
 
39
- let mockedPosts: Array<{ slug: string; series?: string; redirectFrom?: string[] }> = [];
40
+ // Mock-post shape: only `slug` is required; the named fields are the ones
41
+ // production code paths read. Extra fields (full Post shape) are allowed via
42
+ // the index signature so individual tests can pass realistic fixtures without
43
+ // every property having to be enumerated here.
44
+ let mockedPosts: Array<{
45
+ slug: string;
46
+ series?: string;
47
+ redirectFrom?: string[];
48
+ draft?: boolean;
49
+ title?: string;
50
+ [key: string]: unknown;
51
+ }> = [];
40
52
  let mockedNotes: Array<{ slug: string }> = [];
41
53
  let mockedSeries: Record<string, Array<{ slug: string }>> = {};
42
54
  let mockedSeriesData: Record<string, { redirectFrom?: string[]; title?: string }> = {};
@@ -148,7 +160,7 @@ beforeEach(() => {
148
160
  mockedNotes = [];
149
161
  mockedSeries = {};
150
162
  mockedSeriesData = {};
151
- process.env.NODE_ENV = originalNodeEnv;
163
+ restoreEnvVar('NODE_ENV', originalNodeEnv);
152
164
  });
153
165
 
154
166
  afterEach(() => {
@@ -156,7 +168,7 @@ afterEach(() => {
156
168
  mockedNotes = [];
157
169
  mockedSeries = {};
158
170
  mockedSeriesData = {};
159
- process.env.NODE_ENV = originalNodeEnv;
171
+ restoreEnvVar('NODE_ENV', originalNodeEnv);
160
172
  });
161
173
 
162
174
  // ─── Restore real modules ─────────────────────────────────────────────────────
@@ -201,7 +213,7 @@ describe('generateStaticParams — placeholder when content is empty', () => {
201
213
 
202
214
  test('notes/[slug] includes raw and encoded Unicode slug in non-production', async () => {
203
215
  mockedNotes = [{ slug: '推理模型' }];
204
- process.env.NODE_ENV = 'development';
216
+ setEnvVar('NODE_ENV', 'development');
205
217
  const { generateStaticParams } = await import('../../src/app/notes/[slug]/page');
206
218
  const params = generateStaticParams();
207
219
  expect(params).toContainEqual({ slug: '推理模型' });
@@ -210,7 +222,7 @@ describe('generateStaticParams — placeholder when content is empty', () => {
210
222
 
211
223
  test('notes/[slug] includes only raw Unicode slug in production', async () => {
212
224
  mockedNotes = [{ slug: '推理模型' }];
213
- process.env.NODE_ENV = 'production';
225
+ setEnvVar('NODE_ENV', 'production');
214
226
  const { generateStaticParams } = await import('../../src/app/notes/[slug]/page');
215
227
  const params = generateStaticParams();
216
228
  expect(params).toContainEqual({ slug: '推理模型' });
@@ -232,10 +244,10 @@ describe('generateStaticParams — placeholder when content is empty', () => {
232
244
  expect(params).toEqual([{ slug: '_' }]);
233
245
  });
234
246
 
235
- test('books/[slug]/[chapter] returns [{ slug: "_", chapter: "_" }]', async () => {
236
- const { generateStaticParams } = await import('../../src/app/books/[slug]/[chapter]/page');
247
+ test('books/[slug]/[...chapter] returns [{ slug: "_", chapter: ["_"] }]', async () => {
248
+ const { generateStaticParams } = await import('../../src/app/books/[slug]/[...chapter]/page');
237
249
  const params = await generateStaticParams();
238
- expect(params).toEqual([{ slug: '_', chapter: '_' }]);
250
+ expect(params).toEqual([{ slug: '_', chapter: ['_'] }]);
239
251
  });
240
252
  });
241
253
 
@@ -257,7 +269,7 @@ describe('generateStaticParams — placeholder when content is empty', () => {
257
269
 
258
270
  test('series/[slug] includes raw and encoded Unicode slug in non-production', async () => {
259
271
  mockedSeries = { '软件构架设计': [] };
260
- process.env.NODE_ENV = 'development';
272
+ setEnvVar('NODE_ENV', 'development');
261
273
  const { generateStaticParams } = await import('../../src/app/series/[slug]/page');
262
274
  const params = await generateStaticParams();
263
275
  expect(params).toContainEqual({ slug: '软件构架设计' });
@@ -266,7 +278,7 @@ describe('generateStaticParams — placeholder when content is empty', () => {
266
278
 
267
279
  test('series/[slug] includes only raw Unicode slug in production', async () => {
268
280
  mockedSeries = { '软件构架设计': [] };
269
- process.env.NODE_ENV = 'production';
281
+ setEnvVar('NODE_ENV', 'production');
270
282
  const { generateStaticParams } = await import('../../src/app/series/[slug]/page');
271
283
  const params = await generateStaticParams();
272
284
  expect(params).toContainEqual({ slug: '软件构架设计' });
@@ -281,7 +293,7 @@ describe('generateStaticParams — placeholder when content is empty', () => {
281
293
 
282
294
  test('series/[slug]/page/[page] includes encoded Unicode slug in non-production', async () => {
283
295
  mockedSeries = { '软件构架设计': Array.from({ length: 6 }, (_, i) => ({ slug: `p${i + 1}` })) };
284
- process.env.NODE_ENV = 'development';
296
+ setEnvVar('NODE_ENV', 'development');
285
297
  const { generateStaticParams } = await import('../../src/app/series/[slug]/page/[page]/page');
286
298
  const params = await generateStaticParams();
287
299
  expect(params).toContainEqual({ slug: '软件构架设计', page: '2' });
@@ -361,7 +373,7 @@ describe('generateStaticParams — placeholder when content is empty', () => {
361
373
 
362
374
  test('posts/[slug] includes raw and encoded Unicode slug in non-production', async () => {
363
375
  mockedPosts = [{ slug: '中文测试文章' }];
364
- process.env.NODE_ENV = 'development';
376
+ setEnvVar('NODE_ENV', 'development');
365
377
  const { generateStaticParams } = await import('../../src/app/posts/[slug]/page');
366
378
  const params = await generateStaticParams();
367
379
 
@@ -371,7 +383,7 @@ describe('generateStaticParams — placeholder when content is empty', () => {
371
383
 
372
384
  test('posts/[slug] includes only raw Unicode slug in production', async () => {
373
385
  mockedPosts = [{ slug: '中文测试文章' }];
374
- process.env.NODE_ENV = 'production';
386
+ setEnvVar('NODE_ENV', 'production');
375
387
  const { generateStaticParams } = await import('../../src/app/posts/[slug]/page');
376
388
  const params = await generateStaticParams();
377
389
 
@@ -485,7 +497,7 @@ describe('generateStaticParams — placeholder when content is empty', () => {
485
497
 
486
498
  test('[slug]/page does not include single-segment redirectFrom for draft posts in production', async () => {
487
499
  mockedPosts = [{ slug: 'my-post', draft: true, redirectFrom: ['/old-slug'] }];
488
- process.env.NODE_ENV = 'production';
500
+ setEnvVar('NODE_ENV', 'production');
489
501
  const { generateStaticParams } = await import('../../src/app/[slug]/page');
490
502
  const params = await generateStaticParams();
491
503
  expect(params).not.toContainEqual({ slug: 'old-slug' });
@@ -588,7 +600,7 @@ describe('generateStaticParams — placeholder when content is empty', () => {
588
600
  test('[slug]/[postSlug] includes encoded Unicode postSlug variants in non-production', async () => {
589
601
  // Use redirectFrom to place a Unicode postSlug at a 2-segment path — no url mock needed.
590
602
  mockedPosts = [{ slug: 'my-post', redirectFrom: ['/old-prefix/中文文章'] }];
591
- process.env.NODE_ENV = 'development';
603
+ setEnvVar('NODE_ENV', 'development');
592
604
  const { generateStaticParams } = await import('../../src/app/[slug]/[postSlug]/page');
593
605
  const params = await generateStaticParams();
594
606
  expect(params).toContainEqual({ slug: 'old-prefix', postSlug: '中文文章' });
@@ -597,7 +609,7 @@ describe('generateStaticParams — placeholder when content is empty', () => {
597
609
 
598
610
  test('[slug]/[postSlug] includes encoded Unicode prefix variants in non-production', async () => {
599
611
  mockedSeries = { '软件构架设计': [{ slug: 'architecture-post' }] };
600
- process.env.NODE_ENV = 'development';
612
+ setEnvVar('NODE_ENV', 'development');
601
613
  const { generateStaticParams } = await import('../../src/app/[slug]/[postSlug]/page');
602
614
  const params = await generateStaticParams();
603
615
  expect(params).toContainEqual({ slug: '软件构架设计', postSlug: 'architecture-post' });
@@ -606,7 +618,7 @@ describe('generateStaticParams — placeholder when content is empty', () => {
606
618
 
607
619
  test('[slug]/[postSlug] includes encoded Unicode prefix and postSlug variants together in non-production', async () => {
608
620
  mockedSeries = { '软件构架设计': [{ slug: '中文文章' }] };
609
- process.env.NODE_ENV = 'development';
621
+ setEnvVar('NODE_ENV', 'development');
610
622
  const { generateStaticParams } = await import('../../src/app/[slug]/[postSlug]/page');
611
623
  const params = await generateStaticParams();
612
624
  expect(params).toContainEqual({ slug: '软件构架设计', postSlug: '中文文章' });
@@ -617,7 +629,7 @@ describe('generateStaticParams — placeholder when content is empty', () => {
617
629
 
618
630
  test('[slug]/[postSlug] does not include encoded Unicode postSlug variants in production', async () => {
619
631
  mockedPosts = [{ slug: 'my-post', redirectFrom: ['/old-prefix/中文文章'] }];
620
- process.env.NODE_ENV = 'production';
632
+ setEnvVar('NODE_ENV', 'production');
621
633
  const { generateStaticParams } = await import('../../src/app/[slug]/[postSlug]/page');
622
634
  const params = await generateStaticParams();
623
635
  expect(params).toContainEqual({ slug: 'old-prefix', postSlug: '中文文章' });
@@ -641,7 +653,8 @@ describe('generateStaticParams — placeholder when content is empty', () => {
641
653
  imageBaseSlug: 'posts',
642
654
  category: 'Test',
643
655
  tags: [],
644
- readingTime: '1 min read',
656
+ readingMinutes: 1,
657
+ wordCount: 0,
645
658
  }];
646
659
 
647
660
  const page = await import('../../src/app/[slug]/[postSlug]/page');
package/vercel.json ADDED
@@ -0,0 +1,7 @@
1
+ {
2
+ "$schema": "https://openapi.vercel.sh/vercel.json",
3
+ "framework": "nextjs",
4
+ "buildCommand": "bun run build",
5
+ "installCommand": "bun install --frozen-lockfile",
6
+ "outputDirectory": "out"
7
+ }