@hutusi/amytis 1.13.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 +16 -0
- package/GEMINI.md +9 -1
- package/README.md +3 -1
- package/README.zh.md +3 -1
- package/bun.lock +78 -74
- package/content/flows/2026/03/05.md +1 -0
- package/content/flows/2026/03/07.md +2 -0
- 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/scripts/new-flow.ts +1 -0
- 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 -0
- package/src/app/posts/feed.atom/route.ts +9 -0
- package/src/app/posts/feed.xml/route.ts +9 -0
- 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 +1 -1
- 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/PostSidebar.tsx +1 -1
- package/src/components/RecentNotesSection.tsx +4 -0
- package/src/lib/feed-utils.ts +158 -18
- package/src/lib/markdown.ts +16 -4
- 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/src/lib/feed-utils.ts
CHANGED
|
@@ -7,6 +7,7 @@ import rehypeStringify from 'rehype-stringify';
|
|
|
7
7
|
import { getAllPosts, getAllFlows } from './markdown';
|
|
8
8
|
import { siteConfig } from '../../site.config';
|
|
9
9
|
import { getPostUrl, getFlowUrl } from './urls';
|
|
10
|
+
import { resolveLocale } from './i18n';
|
|
10
11
|
|
|
11
12
|
export interface FeedItem {
|
|
12
13
|
title: string;
|
|
@@ -30,39 +31,178 @@ function markdownToHtml(markdown: string): string {
|
|
|
30
31
|
return String(result);
|
|
31
32
|
}
|
|
32
33
|
|
|
34
|
+
export type FeedType = 'main' | 'posts' | 'flows' | 'all';
|
|
35
|
+
|
|
33
36
|
/**
|
|
34
37
|
* Returns feed items for RSS/Atom generation.
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
+
* - 'main': Respects `siteConfig.feed.includeFlows`
|
|
39
|
+
* - 'posts': Only posts
|
|
40
|
+
* - 'flows': Only flows
|
|
41
|
+
* - 'all': Both posts and flows, ignoring `includeFlows`
|
|
38
42
|
*/
|
|
39
|
-
export function getFeedItems(): FeedItem[] {
|
|
43
|
+
export function getFeedItems(feedType: FeedType = 'main', includeFullContent: boolean = false): FeedItem[] {
|
|
40
44
|
const { maxItems, includeFlows } = siteConfig.feed;
|
|
41
45
|
const baseUrl = siteConfig.baseUrl.replace(/\/+$/, '');
|
|
42
46
|
|
|
43
|
-
|
|
47
|
+
let items: FeedItem[] = [];
|
|
48
|
+
|
|
49
|
+
const getPostItems = () => getAllPosts().map((post) => ({
|
|
44
50
|
title: post.title,
|
|
45
51
|
url: `${baseUrl}${getPostUrl(post)}`,
|
|
46
52
|
date: new Date(post.date),
|
|
47
53
|
excerpt: post.excerpt,
|
|
48
|
-
content: markdownToHtml(post.content),
|
|
54
|
+
content: includeFullContent ? markdownToHtml(post.content) : '',
|
|
49
55
|
tags: post.tags || [],
|
|
50
56
|
authors: post.authors,
|
|
51
57
|
}));
|
|
52
58
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
59
|
+
const getFlowItems = () => getAllFlows().map((flow) => ({
|
|
60
|
+
title: flow.title,
|
|
61
|
+
url: `${baseUrl}${getFlowUrl(flow.slug)}`,
|
|
62
|
+
date: new Date(flow.date),
|
|
63
|
+
excerpt: flow.excerpt,
|
|
64
|
+
content: includeFullContent ? markdownToHtml(flow.content) : '',
|
|
65
|
+
tags: flow.tags || [],
|
|
66
|
+
}));
|
|
67
|
+
|
|
68
|
+
if (feedType === 'posts') {
|
|
69
|
+
items = getPostItems();
|
|
70
|
+
} else if (feedType === 'flows') {
|
|
71
|
+
items = getFlowItems();
|
|
72
|
+
} else if (feedType === 'all') {
|
|
73
|
+
items = [...getPostItems(), ...getFlowItems()];
|
|
74
|
+
} else {
|
|
75
|
+
// main
|
|
76
|
+
items = includeFlows ? [...getPostItems(), ...getFlowItems()] : getPostItems();
|
|
65
77
|
}
|
|
66
78
|
|
|
79
|
+
// Sort descending by date
|
|
80
|
+
items.sort((a, b) => b.date.getTime() - a.date.getTime());
|
|
81
|
+
|
|
67
82
|
return maxItems > 0 ? items.slice(0, maxItems) : items;
|
|
68
83
|
}
|
|
84
|
+
|
|
85
|
+
const escapeXml = (v: string) =>
|
|
86
|
+
v.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
87
|
+
.replace(/"/g, '"').replace(/'/g, ''');
|
|
88
|
+
|
|
89
|
+
const escapeCdata = (v: string) => v.replace(/]]>/g, ']]]]><![CDATA[>');
|
|
90
|
+
|
|
91
|
+
export function generateRssFeed(feedType: FeedType, selfUrlPath: string): Response {
|
|
92
|
+
const { format, content: contentMode } = siteConfig.feed;
|
|
93
|
+
if (format === 'atom') {
|
|
94
|
+
return new Response('Not Found', { status: 404 });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const baseUrl = siteConfig.baseUrl.replace(/\/+$/, '');
|
|
98
|
+
const useFullContent = contentMode === 'full';
|
|
99
|
+
const items = getFeedItems(feedType, useFullContent);
|
|
100
|
+
const contentNs = useFullContent ? ' xmlns:content="http://purl.org/rss/modules/content/"' : '';
|
|
101
|
+
const siteTitle = resolveLocale(siteConfig.title);
|
|
102
|
+
const lastBuildDate = items[0]?.date.toUTCString() ?? new Date().toUTCString();
|
|
103
|
+
|
|
104
|
+
const selfUrl = `${baseUrl}${selfUrlPath}`;
|
|
105
|
+
|
|
106
|
+
const imageXml = siteConfig.ogImage
|
|
107
|
+
? `\n <image>\n <url>${escapeXml(baseUrl + siteConfig.ogImage)}</url>\n <title>${escapeXml(siteTitle)}</title>\n <link>${escapeXml(baseUrl)}</link>\n </image>`
|
|
108
|
+
: '';
|
|
109
|
+
|
|
110
|
+
const rssItemsXml = items
|
|
111
|
+
.map((item) => {
|
|
112
|
+
const fullContentXml = useFullContent
|
|
113
|
+
? `\n <content:encoded><![CDATA[${escapeCdata(item.content)}]]></content:encoded>`
|
|
114
|
+
: '';
|
|
115
|
+
const authorsXml = item.authors?.length
|
|
116
|
+
? item.authors.map((a) => `\n <dc:creator><![CDATA[${escapeCdata(a)}]]></dc:creator>`).join('')
|
|
117
|
+
: '';
|
|
118
|
+
return `
|
|
119
|
+
<item>
|
|
120
|
+
<title><![CDATA[${escapeCdata(item.title)}]]></title>
|
|
121
|
+
<link>${escapeXml(item.url)}</link>
|
|
122
|
+
<guid isPermaLink="true">${escapeXml(item.url)}</guid>
|
|
123
|
+
<pubDate>${item.date.toUTCString()}</pubDate>
|
|
124
|
+
<description><![CDATA[${escapeCdata(item.excerpt)}]]></description>${fullContentXml}${authorsXml}
|
|
125
|
+
${item.tags.map((tag) => `<category><![CDATA[${escapeCdata(tag)}]]></category>`).join('')}
|
|
126
|
+
</item>`;
|
|
127
|
+
})
|
|
128
|
+
.join('');
|
|
129
|
+
|
|
130
|
+
const rssXml = `<?xml version="1.0" encoding="UTF-8" ?>
|
|
131
|
+
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/"${contentNs}>
|
|
132
|
+
<channel>
|
|
133
|
+
<title><![CDATA[${escapeCdata(siteTitle)}]]></title>
|
|
134
|
+
<link>${escapeXml(baseUrl)}</link>
|
|
135
|
+
<description><![CDATA[${escapeCdata(resolveLocale(siteConfig.description))}]]></description>
|
|
136
|
+
<language>${siteConfig.i18n.defaultLocale}</language>
|
|
137
|
+
<lastBuildDate>${lastBuildDate}</lastBuildDate>
|
|
138
|
+
<atom:link href="${escapeXml(selfUrl)}" rel="self" type="application/rss+xml" />${imageXml}
|
|
139
|
+
${rssItemsXml}
|
|
140
|
+
</channel>
|
|
141
|
+
</rss>`;
|
|
142
|
+
|
|
143
|
+
return new Response(rssXml, {
|
|
144
|
+
headers: {
|
|
145
|
+
'Content-Type': 'application/rss+xml; charset=utf-8',
|
|
146
|
+
'Cache-Control': 'public, max-age=3600',
|
|
147
|
+
},
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function generateAtomFeed(feedType: FeedType, selfUrlPath: string): Response {
|
|
152
|
+
const { format, content: contentMode } = siteConfig.feed;
|
|
153
|
+
if (format === 'rss') {
|
|
154
|
+
return new Response('Not Found', { status: 404 });
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const baseUrl = siteConfig.baseUrl.replace(/\/+$/, '');
|
|
158
|
+
const useFullContent = contentMode === 'full';
|
|
159
|
+
const items = getFeedItems(feedType, useFullContent);
|
|
160
|
+
const feedUpdated = items[0]?.date.toISOString() ?? new Date().toISOString();
|
|
161
|
+
|
|
162
|
+
const selfUrl = `${baseUrl}${selfUrlPath}`;
|
|
163
|
+
|
|
164
|
+
const hasAllAuthors = items.every(item => item.authors && item.authors.length > 0);
|
|
165
|
+
const siteTitle = resolveLocale(siteConfig.title);
|
|
166
|
+
const defaultAuthor = siteConfig.posts?.authors?.default?.[0];
|
|
167
|
+
const feedAuthorName = defaultAuthor ? defaultAuthor : siteTitle;
|
|
168
|
+
const feedAuthorXml = hasAllAuthors ? '' : `\n <author><name>${escapeXml(feedAuthorName)}</name></author>`;
|
|
169
|
+
|
|
170
|
+
const entriesXml = items
|
|
171
|
+
.map((item) => {
|
|
172
|
+
const contentXml = useFullContent
|
|
173
|
+
? `<content type="html"><![CDATA[${escapeCdata(item.content)}]]></content>\n <summary><![CDATA[${escapeCdata(item.excerpt)}]]></summary>`
|
|
174
|
+
: `<summary><![CDATA[${escapeCdata(item.excerpt)}]]></summary>`;
|
|
175
|
+
const authorsXml = item.authors?.map((a) => `<author><name>${escapeXml(a)}</name></author>`).join('') ?? '';
|
|
176
|
+
const categoriesXml = item.tags.map((tag) => `<category term="${escapeXml(tag)}" />`).join('');
|
|
177
|
+
return `
|
|
178
|
+
<entry>
|
|
179
|
+
<title><![CDATA[${escapeCdata(item.title)}]]></title>
|
|
180
|
+
<link href="${escapeXml(item.url)}" />
|
|
181
|
+
<id>${escapeXml(item.url)}</id>
|
|
182
|
+
<published>${item.date.toISOString()}</published>
|
|
183
|
+
<updated>${item.date.toISOString()}</updated>
|
|
184
|
+
${contentXml}
|
|
185
|
+
${authorsXml}
|
|
186
|
+
${categoriesXml}
|
|
187
|
+
</entry>`;
|
|
188
|
+
})
|
|
189
|
+
.join('');
|
|
190
|
+
|
|
191
|
+
const atomXml = `<?xml version="1.0" encoding="UTF-8" ?>
|
|
192
|
+
<feed xmlns="http://www.w3.org/2005/Atom">
|
|
193
|
+
<title><![CDATA[${escapeCdata(resolveLocale(siteConfig.title))}]]></title>
|
|
194
|
+
<link href="${escapeXml(baseUrl)}" />
|
|
195
|
+
<link href="${escapeXml(selfUrl)}" rel="self" type="application/atom+xml" />
|
|
196
|
+
<id>${escapeXml(selfUrl)}</id>
|
|
197
|
+
<updated>${feedUpdated}</updated>
|
|
198
|
+
<subtitle><![CDATA[${escapeCdata(resolveLocale(siteConfig.description))}]]></subtitle>${feedAuthorXml}
|
|
199
|
+
${entriesXml}
|
|
200
|
+
</feed>`;
|
|
201
|
+
|
|
202
|
+
return new Response(atomXml, {
|
|
203
|
+
headers: {
|
|
204
|
+
'Content-Type': 'application/atom+xml; charset=utf-8',
|
|
205
|
+
'Cache-Control': 'public, max-age=3600',
|
|
206
|
+
},
|
|
207
|
+
});
|
|
208
|
+
}
|
package/src/lib/markdown.ts
CHANGED
|
@@ -883,19 +883,30 @@ export function getCollectionPosts(collectionSlug: string): PostData[] {
|
|
|
883
883
|
const data = getSeriesData(collectionSlug);
|
|
884
884
|
if (data?.type !== 'collection' || !data.items) return [];
|
|
885
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]));
|
|
886
891
|
const seen = new Set<string>();
|
|
892
|
+
|
|
887
893
|
return data.items
|
|
888
894
|
.flatMap(item => {
|
|
889
895
|
if ('series' in item) {
|
|
890
896
|
const posts = getSeriesPosts(item.series);
|
|
891
897
|
return item.exclude ? posts.filter(p => !item.exclude!.includes(p.slug)) : posts;
|
|
892
898
|
}
|
|
893
|
-
|
|
899
|
+
|
|
900
|
+
const post = item.post.includes('/')
|
|
901
|
+
? postIndex.get(item.post)
|
|
902
|
+
: getPostBySlug(item.post);
|
|
903
|
+
|
|
894
904
|
return post ? [post] : [];
|
|
895
905
|
})
|
|
896
906
|
.filter(post => {
|
|
897
|
-
|
|
898
|
-
seen.
|
|
907
|
+
const key = getCollectionKey(post);
|
|
908
|
+
if (seen.has(key)) return false;
|
|
909
|
+
seen.add(key);
|
|
899
910
|
return true;
|
|
900
911
|
});
|
|
901
912
|
}
|
|
@@ -1185,6 +1196,7 @@ function parseFlowFile(fullPath: string, slug: string): FlowData {
|
|
|
1185
1196
|
}
|
|
1186
1197
|
const data = parsed.data;
|
|
1187
1198
|
|
|
1199
|
+
const h1Match = content.match(/^\s*#\s+(.+)/);
|
|
1188
1200
|
const contentWithoutH1 = content.replace(/^\s*#\s+[^\n]+/, '').trim();
|
|
1189
1201
|
const date = data.date || slug.replace(/\//g, '-'); // slug is YYYY/MM/DD, convert to YYYY-MM-DD
|
|
1190
1202
|
const excerpt = generateExcerpt(contentWithoutH1);
|
|
@@ -1193,7 +1205,7 @@ function parseFlowFile(fullPath: string, slug: string): FlowData {
|
|
|
1193
1205
|
return {
|
|
1194
1206
|
slug,
|
|
1195
1207
|
date,
|
|
1196
|
-
title: data.title
|
|
1208
|
+
title: data.title?.trim() || h1Match?.[1]?.trim() || date, // frontmatter(non-empty) → H1 → date
|
|
1197
1209
|
tags: data.tags,
|
|
1198
1210
|
draft: data.draft,
|
|
1199
1211
|
commentable: data.commentable,
|
|
@@ -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
|
+
});
|