@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.
- package/CHANGELOG.md +29 -0
- package/GEMINI.md +9 -1
- package/README.md +26 -17
- package/README.zh.md +180 -100
- package/bun.lock +78 -74
- package/content/books/notes-on-thinking/cost-of-certainty.mdx +9 -0
- package/content/books/notes-on-thinking/index.mdx +16 -0
- package/content/books/notes-on-thinking/mental-models.mdx +9 -0
- package/content/books/the-pragmatic-writer/finding-your-voice.mdx +9 -0
- package/content/books/the-pragmatic-writer/index.mdx +18 -0
- package/content/books/the-pragmatic-writer/the-editing-loop.mdx +9 -0
- package/content/books/the-pragmatic-writer/why-writing-matters.mdx +9 -0
- package/content/flows/2026/03/01.md +9 -0
- package/content/flows/2026/03/03.md +9 -0
- package/content/flows/2026/03/05.md +10 -0
- package/content/flows/2026/03/07.md +11 -0
- package/content/posts/images/vibrant-waves.jpg +0 -0
- package/content/posts/welcome-to-amytis.mdx +3 -0
- package/content/series/markdown-showcase/index.mdx +2 -1
- package/content/series/markdown-showcase/mathematical-notation.mdx +8 -4
- package/content/series/markdown-showcase/syntax-highlighting.mdx +9 -5
- package/content/series/markdown-showcase/visuals-and-diagrams.mdx +8 -4
- 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
- package/content/series/modern-web-dev/index.mdx +4 -2
- package/docs/ARCHITECTURE.md +8 -1
- package/docs/DIGITAL_GARDEN.md +22 -1
- package/package.json +12 -12
- package/public/next-image-export-optimizer-hashes.json +3 -2
- package/scripts/new-flow.ts +1 -0
- package/site.config.example.ts +3 -4
- package/site.config.ts +6 -7
- package/src/app/[slug]/[postSlug]/page.tsx +19 -2
- package/src/app/[slug]/page/[page]/page.tsx +26 -5
- package/src/app/[slug]/page.tsx +28 -8
- package/src/app/all.atom/route.ts +7 -0
- package/src/app/all.xml/route.ts +7 -0
- package/src/app/archive/page.tsx +7 -4
- package/src/app/feed.atom/route.ts +2 -57
- package/src/app/feed.xml/route.ts +2 -64
- package/src/app/flows/[year]/[month]/[day]/page.tsx +13 -0
- package/src/app/flows/feed.atom/route.ts +7 -0
- package/src/app/flows/feed.xml/route.ts +7 -0
- package/src/app/page.tsx +1 -2
- package/src/app/posts/[slug]/page.tsx +28 -9
- package/src/app/posts/feed.atom/route.ts +9 -0
- package/src/app/posts/feed.xml/route.ts +9 -0
- package/src/app/series/[slug]/page.tsx +46 -4
- package/src/components/CuratedSeriesSection.tsx +7 -11
- package/src/components/FeaturedStoriesSection.tsx +1 -1
- package/src/components/FlowCalendarSidebar.tsx +1 -1
- package/src/components/FlowContent.tsx +2 -1
- package/src/components/FlowTimelineEntry.tsx +7 -1
- package/src/components/Footer.tsx +6 -6
- package/src/components/HorizontalScroll.tsx +5 -14
- package/src/components/MarkdownRenderer.test.tsx +6 -0
- package/src/components/MarkdownRenderer.tsx +18 -16
- package/src/components/Navbar.tsx +1 -1
- package/src/components/PostList.tsx +20 -36
- package/src/components/PostSidebar.tsx +1 -1
- package/src/components/RecentNotesSection.tsx +4 -0
- package/src/components/SelectedBooksSection.tsx +65 -25
- package/src/components/SeriesCatalog.tsx +9 -7
- package/src/i18n/translations.ts +2 -0
- package/src/layouts/PostLayout.tsx +1 -1
- package/src/layouts/SimpleLayout.tsx +3 -3
- package/src/lib/feed-utils.ts +158 -18
- package/src/lib/markdown.ts +26 -5
- package/src/lib/urls.ts +9 -4
- package/tests/e2e/mobile/mobile-compat.spec.ts +58 -0
- package/tests/e2e/navigation.test.ts +26 -0
- package/tests/integration/collections.test.ts +17 -2
- package/tests/integration/feed-utils.test.ts +52 -0
- package/tests/integration/flow-title.test.ts +53 -0
- package/tests/integration/markdown-features.test.ts +3 -3
- package/tests/integration/reading-time-headings.test.ts +2 -2
- package/tests/unit/static-params.test.ts +155 -22
- package/tests/unit/urls.test.ts +10 -12
- /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
package/src/lib/markdown.ts
CHANGED
|
@@ -121,6 +121,8 @@ export interface PostData {
|
|
|
121
121
|
content: string;
|
|
122
122
|
headings: Heading[];
|
|
123
123
|
contentLocales?: Record<string, { content: string; title?: string; excerpt?: string; headings?: Heading[] }>;
|
|
124
|
+
/** Public-relative base path used for resolving co-located images (e.g. "posts/my-post" or "posts" for root flat files). */
|
|
125
|
+
imageBaseSlug: string;
|
|
124
126
|
}
|
|
125
127
|
|
|
126
128
|
export function calculateReadingTime(content: string): string {
|
|
@@ -235,6 +237,12 @@ function getSeriesTitle(slug: string): string | undefined {
|
|
|
235
237
|
function parseMarkdownFile(fullPath: string, slug: string, dateFromFileName?: string, seriesName?: string): PostData {
|
|
236
238
|
const fileContents = fs.readFileSync(fullPath, 'utf8');
|
|
237
239
|
const { data: rawData, content } = matter(fileContents);
|
|
240
|
+
// Flat files directly in content/posts/ share the posts root public directory for images.
|
|
241
|
+
// Folder-based posts and series posts each have their own public subdirectory.
|
|
242
|
+
const isRootFlatPost = path.basename(fullPath) !== 'index.mdx' &&
|
|
243
|
+
path.basename(fullPath) !== 'index.md' &&
|
|
244
|
+
path.dirname(fullPath) === contentDirectory;
|
|
245
|
+
const imageBaseSlug = isRootFlatPost ? 'posts' : `posts/${slug}`;
|
|
238
246
|
|
|
239
247
|
const parsed = PostSchema.safeParse(rawData);
|
|
240
248
|
if (!parsed.success) {
|
|
@@ -279,7 +287,7 @@ function parseMarkdownFile(fullPath: string, slug: string, dateFromFileName?: st
|
|
|
279
287
|
let coverImage = data.coverImage;
|
|
280
288
|
if (coverImage && !coverImage.startsWith('http') && !coverImage.startsWith('/') && !coverImage.startsWith('text:')) {
|
|
281
289
|
const cleanPath = coverImage.replace(/^\.\//, '');
|
|
282
|
-
coverImage =
|
|
290
|
+
coverImage = `/${imageBaseSlug}/${cleanPath}`;
|
|
283
291
|
}
|
|
284
292
|
|
|
285
293
|
return {
|
|
@@ -310,6 +318,7 @@ function parseMarkdownFile(fullPath: string, slug: string, dateFromFileName?: st
|
|
|
310
318
|
readingTime,
|
|
311
319
|
content: contentWithoutH1,
|
|
312
320
|
headings,
|
|
321
|
+
imageBaseSlug,
|
|
313
322
|
};
|
|
314
323
|
}
|
|
315
324
|
|
|
@@ -874,19 +883,30 @@ export function getCollectionPosts(collectionSlug: string): PostData[] {
|
|
|
874
883
|
const data = getSeriesData(collectionSlug);
|
|
875
884
|
if (data?.type !== 'collection' || !data.items) return [];
|
|
876
885
|
|
|
886
|
+
const getCollectionKey = (post: PostData) =>
|
|
887
|
+
post.series ? `${post.series}/${post.slug}` : `posts/${post.slug}`;
|
|
888
|
+
|
|
889
|
+
const allPosts = getAllPosts();
|
|
890
|
+
const postIndex = new Map(allPosts.map((post) => [getCollectionKey(post), post]));
|
|
877
891
|
const seen = new Set<string>();
|
|
892
|
+
|
|
878
893
|
return data.items
|
|
879
894
|
.flatMap(item => {
|
|
880
895
|
if ('series' in item) {
|
|
881
896
|
const posts = getSeriesPosts(item.series);
|
|
882
897
|
return item.exclude ? posts.filter(p => !item.exclude!.includes(p.slug)) : posts;
|
|
883
898
|
}
|
|
884
|
-
|
|
899
|
+
|
|
900
|
+
const post = item.post.includes('/')
|
|
901
|
+
? postIndex.get(item.post)
|
|
902
|
+
: getPostBySlug(item.post);
|
|
903
|
+
|
|
885
904
|
return post ? [post] : [];
|
|
886
905
|
})
|
|
887
906
|
.filter(post => {
|
|
888
|
-
|
|
889
|
-
seen.
|
|
907
|
+
const key = getCollectionKey(post);
|
|
908
|
+
if (seen.has(key)) return false;
|
|
909
|
+
seen.add(key);
|
|
890
910
|
return true;
|
|
891
911
|
});
|
|
892
912
|
}
|
|
@@ -1176,6 +1196,7 @@ function parseFlowFile(fullPath: string, slug: string): FlowData {
|
|
|
1176
1196
|
}
|
|
1177
1197
|
const data = parsed.data;
|
|
1178
1198
|
|
|
1199
|
+
const h1Match = content.match(/^\s*#\s+(.+)/);
|
|
1179
1200
|
const contentWithoutH1 = content.replace(/^\s*#\s+[^\n]+/, '').trim();
|
|
1180
1201
|
const date = data.date || slug.replace(/\//g, '-'); // slug is YYYY/MM/DD, convert to YYYY-MM-DD
|
|
1181
1202
|
const excerpt = generateExcerpt(contentWithoutH1);
|
|
@@ -1184,7 +1205,7 @@ function parseFlowFile(fullPath: string, slug: string): FlowData {
|
|
|
1184
1205
|
return {
|
|
1185
1206
|
slug,
|
|
1186
1207
|
date,
|
|
1187
|
-
title: data.title
|
|
1208
|
+
title: data.title?.trim() || h1Match?.[1]?.trim() || date, // frontmatter(non-empty) → H1 → date
|
|
1188
1209
|
tags: data.tags,
|
|
1189
1210
|
draft: data.draft,
|
|
1190
1211
|
commentable: data.commentable,
|
package/src/lib/urls.ts
CHANGED
|
@@ -6,8 +6,8 @@ function normalizeSegment(segment: string): string {
|
|
|
6
6
|
}
|
|
7
7
|
|
|
8
8
|
// Top-level route segments reserved by the app — series slugs must not collide with these.
|
|
9
|
-
const RESERVED_ROUTE_SEGMENTS = new Set([
|
|
10
|
-
'series', 'books', 'flows', 'tags', 'authors', 'archive', 'notes', 'graph', 'page', 'api',
|
|
9
|
+
export const RESERVED_ROUTE_SEGMENTS = new Set([
|
|
10
|
+
'posts', 'series', 'books', 'flows', 'tags', 'authors', 'archive', 'notes', 'graph', 'search', 'page', 'api',
|
|
11
11
|
]);
|
|
12
12
|
|
|
13
13
|
export function getPostsBasePath(): string {
|
|
@@ -22,7 +22,7 @@ export function getSeriesCustomPaths(): Record<string, string> {
|
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
export function getSeriesAutoPaths(): boolean {
|
|
25
|
-
return siteConfig.series?.autoPaths ??
|
|
25
|
+
return siteConfig.series?.autoPaths ?? true;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
/**
|
|
@@ -41,7 +41,7 @@ export function validateSeriesAutoPaths(seriesSlugs: string[], extraReserved: st
|
|
|
41
41
|
const reserved = new Set([...RESERVED_ROUTE_SEGMENTS, basePath, ...extraReserved]);
|
|
42
42
|
|
|
43
43
|
for (const slug of seriesSlugs) {
|
|
44
|
-
if (slug
|
|
44
|
+
if (Object.hasOwn(customPaths, slug)) continue; // Has an explicit override — skip
|
|
45
45
|
if (reserved.has(slug)) {
|
|
46
46
|
throw new Error(
|
|
47
47
|
`[amytis] Series slug "${slug}" conflicts with the reserved route "/${slug}". ` +
|
|
@@ -71,6 +71,11 @@ export function getPostsPageUrl(page: number): string {
|
|
|
71
71
|
return `/${getPostsBasePath()}/page/${page}`;
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
/** Returns the series listing URL. */
|
|
75
|
+
export function getSeriesListUrl(): string {
|
|
76
|
+
return '/series';
|
|
77
|
+
}
|
|
78
|
+
|
|
74
79
|
/** Returns the books listing URL. */
|
|
75
80
|
export function getBooksListUrl(): string {
|
|
76
81
|
return '/books';
|
|
@@ -404,4 +404,62 @@ test.describe('Mobile Compatibility', () => {
|
|
|
404
404
|
expect(await hasNoHorizontalOverflow(page)).toBe(true);
|
|
405
405
|
});
|
|
406
406
|
});
|
|
407
|
+
|
|
408
|
+
// ── Post list cover image links ────────────────────────────────────────────
|
|
409
|
+
test.describe('Post list cover image links', () => {
|
|
410
|
+
test('cover image in post list is wrapped in a link', async ({ page }) => {
|
|
411
|
+
await page.goto('/posts');
|
|
412
|
+
await page.waitForLoadState('load');
|
|
413
|
+
|
|
414
|
+
// Find the first cover image inside the post list and check its parent link
|
|
415
|
+
const imageLink = page.locator('article a:has(img)').first();
|
|
416
|
+
await expect(imageLink).toHaveAttribute('href', /.+/);
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
test('clicking post list cover image navigates to post page', async ({ page }) => {
|
|
420
|
+
await page.goto('/posts');
|
|
421
|
+
await page.waitForLoadState('load');
|
|
422
|
+
|
|
423
|
+
const imageLink = page.locator('article a:has(img)').first();
|
|
424
|
+
const href = await imageLink.getAttribute('href');
|
|
425
|
+
if (!href) { test.skip(); return; }
|
|
426
|
+
|
|
427
|
+
await imageLink.click();
|
|
428
|
+
await page.waitForLoadState('domcontentloaded');
|
|
429
|
+
expect(page.url()).toContain(href);
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
test('cover image in series catalog is wrapped in a link', async ({ page }) => {
|
|
433
|
+
await page.goto('/series');
|
|
434
|
+
await page.waitForLoadState('load');
|
|
435
|
+
const seriesLink = page.locator('a[href^="/series/"]').first();
|
|
436
|
+
const seriesHref = await seriesLink.getAttribute('href');
|
|
437
|
+
if (!seriesHref) { test.skip(); return; }
|
|
438
|
+
|
|
439
|
+
await page.goto(seriesHref);
|
|
440
|
+
await page.waitForLoadState('load');
|
|
441
|
+
|
|
442
|
+
const imageLink = page.locator('article a:has(img)').first();
|
|
443
|
+
await expect(imageLink).toHaveAttribute('href', /.+/);
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
test('clicking series catalog cover image navigates to post page', async ({ page }) => {
|
|
447
|
+
await page.goto('/series');
|
|
448
|
+
await page.waitForLoadState('load');
|
|
449
|
+
const seriesLink = page.locator('a[href^="/series/"]').first();
|
|
450
|
+
const seriesHref = await seriesLink.getAttribute('href');
|
|
451
|
+
if (!seriesHref) { test.skip(); return; }
|
|
452
|
+
|
|
453
|
+
await page.goto(seriesHref);
|
|
454
|
+
await page.waitForLoadState('load');
|
|
455
|
+
|
|
456
|
+
const imageLink = page.locator('article a:has(img)').first();
|
|
457
|
+
const href = await imageLink.getAttribute('href');
|
|
458
|
+
if (!href) { test.skip(); return; }
|
|
459
|
+
|
|
460
|
+
await imageLink.click();
|
|
461
|
+
await page.waitForLoadState('domcontentloaded');
|
|
462
|
+
expect(page.url()).toContain(href);
|
|
463
|
+
});
|
|
464
|
+
});
|
|
407
465
|
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import { siteConfig } from "../../site.config";
|
|
2
3
|
|
|
3
4
|
const BASE_URL = "http://localhost:3000";
|
|
4
5
|
|
|
@@ -54,6 +55,7 @@ describe("E2E: Navigation & Assets", () => {
|
|
|
54
55
|
|
|
55
56
|
test("feed.atom should be a valid Atom feed", async () => {
|
|
56
57
|
if (!(await isServerRunning())) return;
|
|
58
|
+
if (siteConfig.feed.format === "rss") return; // skip if Atom is disabled
|
|
57
59
|
|
|
58
60
|
const response = await fetch(`${BASE_URL}/feed.atom`);
|
|
59
61
|
expect(response.status).toBe(200);
|
|
@@ -62,4 +64,28 @@ describe("E2E: Navigation & Assets", () => {
|
|
|
62
64
|
expect(text).toContain('xmlns="http://www.w3.org/2005/Atom"');
|
|
63
65
|
expect(text).toContain("<entry>");
|
|
64
66
|
});
|
|
67
|
+
|
|
68
|
+
test("type-specific feeds should be accessible", async () => {
|
|
69
|
+
if (!(await isServerRunning())) return;
|
|
70
|
+
|
|
71
|
+
const feedUrls = [
|
|
72
|
+
"/posts/feed.xml",
|
|
73
|
+
"/flows/feed.xml",
|
|
74
|
+
"/all.xml",
|
|
75
|
+
];
|
|
76
|
+
|
|
77
|
+
if (siteConfig.feed.format === "atom" || siteConfig.feed.format === "both") {
|
|
78
|
+
feedUrls.push(
|
|
79
|
+
"/posts/feed.atom",
|
|
80
|
+
"/flows/feed.atom",
|
|
81
|
+
"/all.atom"
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
for (const url of feedUrls) {
|
|
86
|
+
const response = await fetch(`${BASE_URL}${url}`);
|
|
87
|
+
expect(response.status).toBe(200);
|
|
88
|
+
expect(response.headers.get("content-type")).toContain("xml");
|
|
89
|
+
}
|
|
90
|
+
});
|
|
65
91
|
});
|
|
@@ -44,6 +44,21 @@ describe("Integration: Collections", () => {
|
|
|
44
44
|
expect(slugs).toContain("understanding-react-hooks");
|
|
45
45
|
});
|
|
46
46
|
|
|
47
|
+
test("getCollectionPosts resolves namespaced post entries properly", () => {
|
|
48
|
+
// The modern-web-dev collection uses the "posts/asynchronous-javascript" syntax.
|
|
49
|
+
// This test ensures that the namespaced resolution logic successfully found it.
|
|
50
|
+
const posts = getCollectionPosts("modern-web-dev");
|
|
51
|
+
const foundPost = posts.find(p => p.slug === "asynchronous-javascript");
|
|
52
|
+
expect(foundPost).toBeDefined();
|
|
53
|
+
// Because the namespace was "posts/", its series should be undefined
|
|
54
|
+
expect(foundPost!.series).toBeUndefined();
|
|
55
|
+
|
|
56
|
+
// Also verify series-based namespace
|
|
57
|
+
const seriesPost = posts.find(p => p.slug === "02-architecture");
|
|
58
|
+
expect(seriesPost).toBeDefined();
|
|
59
|
+
expect(seriesPost!.series).toBe("digital-garden");
|
|
60
|
+
});
|
|
61
|
+
|
|
47
62
|
test("getCollectionPosts includes posts from referenced series", () => {
|
|
48
63
|
const posts = getCollectionPosts("modern-web-dev");
|
|
49
64
|
const slugs = posts.map((p) => p.slug);
|
|
@@ -108,8 +123,8 @@ describe("Integration: Collections", () => {
|
|
|
108
123
|
test("getAllSeries includes collection with correct post count", () => {
|
|
109
124
|
const all = getAllSeries();
|
|
110
125
|
expect(all).toHaveProperty("modern-web-dev");
|
|
111
|
-
// modern-web-dev has 2 standalone posts +
|
|
112
|
-
expect(all["modern-web-dev"].length).toBe(
|
|
126
|
+
// modern-web-dev has 2 standalone posts + 4 from nextjs-deep-dive
|
|
127
|
+
expect(all["modern-web-dev"].length).toBe(6);
|
|
113
128
|
});
|
|
114
129
|
|
|
115
130
|
test("getAllSeries collection posts are sorted by date descending", () => {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import { getFeedItems } from "../../src/lib/feed-utils";
|
|
3
3
|
import { getAllPosts, getAllFlows } from "../../src/lib/markdown";
|
|
4
|
+
import { getPostUrl, getFlowUrl } from "../../src/lib/urls";
|
|
4
5
|
import { siteConfig } from "../../site.config";
|
|
5
6
|
|
|
6
7
|
describe("Integration: Feed Utils", () => {
|
|
@@ -142,4 +143,55 @@ describe("Integration: Feed Utils", () => {
|
|
|
142
143
|
}
|
|
143
144
|
});
|
|
144
145
|
});
|
|
146
|
+
|
|
147
|
+
test("feedType 'posts' returns only post items", () => {
|
|
148
|
+
const originalMaxItems = siteConfig.feed.maxItems;
|
|
149
|
+
try {
|
|
150
|
+
siteConfig.feed.maxItems = 0;
|
|
151
|
+
const items = getFeedItems('posts');
|
|
152
|
+
const allPosts = getAllPosts();
|
|
153
|
+
const baseUrl = siteConfig.baseUrl.replace(/\/+$/, "");
|
|
154
|
+
expect(items.length).toBe(allPosts.length);
|
|
155
|
+
expect(items.map((item) => item.url).sort()).toEqual(
|
|
156
|
+
allPosts.map((post) => `${baseUrl}${getPostUrl(post)}`).sort()
|
|
157
|
+
);
|
|
158
|
+
} finally {
|
|
159
|
+
siteConfig.feed.maxItems = originalMaxItems;
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("feedType 'flows' returns only flow items", () => {
|
|
164
|
+
const originalMaxItems = siteConfig.feed.maxItems;
|
|
165
|
+
try {
|
|
166
|
+
siteConfig.feed.maxItems = 0;
|
|
167
|
+
const items = getFeedItems('flows');
|
|
168
|
+
const allFlows = getAllFlows();
|
|
169
|
+
const baseUrl = siteConfig.baseUrl.replace(/\/+$/, "");
|
|
170
|
+
expect(items.length).toBe(allFlows.length);
|
|
171
|
+
expect(items.map((item) => item.url).sort()).toEqual(
|
|
172
|
+
allFlows.map((flow) => `${baseUrl}${getFlowUrl(flow.slug)}`).sort()
|
|
173
|
+
);
|
|
174
|
+
} finally {
|
|
175
|
+
siteConfig.feed.maxItems = originalMaxItems;
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("feedType 'all' returns both post and flow items", () => {
|
|
180
|
+
const originalMaxItems = siteConfig.feed.maxItems;
|
|
181
|
+
try {
|
|
182
|
+
siteConfig.feed.maxItems = 0;
|
|
183
|
+
const items = getFeedItems('all');
|
|
184
|
+
const allPosts = getAllPosts();
|
|
185
|
+
const allFlows = getAllFlows();
|
|
186
|
+
const baseUrl = siteConfig.baseUrl.replace(/\/+$/, "");
|
|
187
|
+
expect(items.map((item) => item.url).sort()).toEqual(
|
|
188
|
+
[
|
|
189
|
+
...allPosts.map((post) => `${baseUrl}${getPostUrl(post)}`),
|
|
190
|
+
...allFlows.map((flow) => `${baseUrl}${getFlowUrl(flow.slug)}`),
|
|
191
|
+
].sort()
|
|
192
|
+
);
|
|
193
|
+
} finally {
|
|
194
|
+
siteConfig.feed.maxItems = originalMaxItems;
|
|
195
|
+
}
|
|
196
|
+
});
|
|
145
197
|
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { getFlowBySlug, getAllFlows } from "../../src/lib/markdown";
|
|
3
|
+
|
|
4
|
+
describe("Integration: Flow Title Resolution", () => {
|
|
5
|
+
test("frontmatter title takes priority over H1 and date", () => {
|
|
6
|
+
// content/flows/2026/03/05.md has `title: 'JSDoc type comments'` in frontmatter
|
|
7
|
+
const flow = getFlowBySlug("2026/03/05");
|
|
8
|
+
expect(flow).not.toBeNull();
|
|
9
|
+
expect(flow!.title).toBe("JSDoc type comments");
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test("H1 heading is extracted as title when no frontmatter title", () => {
|
|
13
|
+
// content/flows/2026/03/07.md has no frontmatter title but has `# Using Claude Code`
|
|
14
|
+
const flow = getFlowBySlug("2026/03/07");
|
|
15
|
+
expect(flow).not.toBeNull();
|
|
16
|
+
expect(flow!.title).toBe("Using Claude Code");
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("date is used as fallback when no frontmatter title or H1", () => {
|
|
20
|
+
// content/flows/2026/02/05.md has no title and no H1
|
|
21
|
+
const flow = getFlowBySlug("2026/02/05");
|
|
22
|
+
expect(flow).not.toBeNull();
|
|
23
|
+
expect(flow!.title).toBe("2026-02-05");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("H1 heading is stripped from content body", () => {
|
|
27
|
+
const flow = getFlowBySlug("2026/03/07");
|
|
28
|
+
expect(flow).not.toBeNull();
|
|
29
|
+
// The H1 should be extracted as title but removed from content
|
|
30
|
+
expect(flow!.content).not.toMatch(/^#\s+Using Claude Code/m);
|
|
31
|
+
// The body content should still be present
|
|
32
|
+
expect(flow!.content).toContain("Claude Code");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("every flow has a non-empty title", () => {
|
|
36
|
+
const flows = getAllFlows();
|
|
37
|
+
expect(flows.length).toBeGreaterThan(0);
|
|
38
|
+
flows.forEach((flow) => {
|
|
39
|
+
expect(flow.title).toBeTruthy();
|
|
40
|
+
expect(flow.title.trim().length).toBeGreaterThan(0);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("flow with frontmatter title preserves content H1 independently", () => {
|
|
45
|
+
// content/flows/2026/03/05.md has frontmatter title — any H1 in content
|
|
46
|
+
// should still be stripped, but title comes from frontmatter
|
|
47
|
+
const flow = getFlowBySlug("2026/03/05");
|
|
48
|
+
expect(flow).not.toBeNull();
|
|
49
|
+
expect(flow!.title).toBe("JSDoc type comments");
|
|
50
|
+
// Content should not start with an H1
|
|
51
|
+
expect(flow!.content).not.toMatch(/^\s*#\s+/);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
@@ -3,14 +3,14 @@ import { getPostBySlug } from "../../src/lib/markdown";
|
|
|
3
3
|
|
|
4
4
|
describe("Integration: Markdown Features", () => {
|
|
5
5
|
test("should correctly load multilingual post", () => {
|
|
6
|
-
const post = getPostBySlug("multilingual-test");
|
|
6
|
+
const post = getPostBySlug("multilingual-test-中文长标题");
|
|
7
7
|
expect(post).not.toBeNull();
|
|
8
8
|
expect(post?.title).toContain("多语言测试");
|
|
9
9
|
expect(post?.latex).toBe(true);
|
|
10
10
|
});
|
|
11
11
|
|
|
12
12
|
test("should generate correct Unicode IDs for TOC", () => {
|
|
13
|
-
const post = getPostBySlug("multilingual-test");
|
|
13
|
+
const post = getPostBySlug("multilingual-test-中文长标题");
|
|
14
14
|
expect(post).not.toBeNull();
|
|
15
15
|
|
|
16
16
|
// Check headings
|
|
@@ -43,7 +43,7 @@ describe("Integration: Markdown Features", () => {
|
|
|
43
43
|
});
|
|
44
44
|
|
|
45
45
|
test("should correctly identify latex enabled posts", () => {
|
|
46
|
-
const post = getPostBySlug("multilingual-test");
|
|
46
|
+
const post = getPostBySlug("multilingual-test-中文长标题");
|
|
47
47
|
expect(post?.latex).toBe(true);
|
|
48
48
|
|
|
49
49
|
const otherPost = getPostBySlug("hello-world"); // Assuming this doesn't exist or doesn't have latex
|
|
@@ -63,9 +63,9 @@ describe("Integration: Reading Time & Headings", () => {
|
|
|
63
63
|
});
|
|
64
64
|
|
|
65
65
|
test("multilingual post has headings with correct IDs", () => {
|
|
66
|
-
const post = getPostBySlug("multilingual-test");
|
|
66
|
+
const post = getPostBySlug("multilingual-test-中文长标题");
|
|
67
67
|
if (!post) {
|
|
68
|
-
console.warn("Skipping: multilingual-test post not found");
|
|
68
|
+
console.warn("Skipping: multilingual-test-中文长标题 post not found");
|
|
69
69
|
return;
|
|
70
70
|
}
|
|
71
71
|
|