@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.
Files changed (38) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/GEMINI.md +9 -1
  3. package/README.md +3 -1
  4. package/README.zh.md +3 -1
  5. package/bun.lock +78 -74
  6. package/content/flows/2026/03/05.md +1 -0
  7. package/content/flows/2026/03/07.md +2 -0
  8. package/content/series/modern-web-dev/index.mdx +4 -2
  9. package/docs/ARCHITECTURE.md +8 -1
  10. package/docs/DIGITAL_GARDEN.md +22 -1
  11. package/package.json +12 -12
  12. package/scripts/new-flow.ts +1 -0
  13. package/src/app/all.atom/route.ts +7 -0
  14. package/src/app/all.xml/route.ts +7 -0
  15. package/src/app/archive/page.tsx +7 -4
  16. package/src/app/feed.atom/route.ts +2 -57
  17. package/src/app/feed.xml/route.ts +2 -64
  18. package/src/app/flows/[year]/[month]/[day]/page.tsx +13 -0
  19. package/src/app/flows/feed.atom/route.ts +7 -0
  20. package/src/app/flows/feed.xml/route.ts +7 -0
  21. package/src/app/page.tsx +1 -0
  22. package/src/app/posts/feed.atom/route.ts +9 -0
  23. package/src/app/posts/feed.xml/route.ts +9 -0
  24. package/src/components/FlowCalendarSidebar.tsx +1 -1
  25. package/src/components/FlowContent.tsx +2 -1
  26. package/src/components/FlowTimelineEntry.tsx +7 -1
  27. package/src/components/Footer.tsx +1 -1
  28. package/src/components/MarkdownRenderer.test.tsx +6 -0
  29. package/src/components/MarkdownRenderer.tsx +18 -16
  30. package/src/components/Navbar.tsx +1 -1
  31. package/src/components/PostSidebar.tsx +1 -1
  32. package/src/components/RecentNotesSection.tsx +4 -0
  33. package/src/lib/feed-utils.ts +158 -18
  34. package/src/lib/markdown.ts +16 -4
  35. package/tests/e2e/navigation.test.ts +26 -0
  36. package/tests/integration/collections.test.ts +17 -2
  37. package/tests/integration/feed-utils.test.ts +52 -0
  38. package/tests/integration/flow-title.test.ts +53 -0
@@ -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
- * Includes all published posts (converted to HTML) and optionally flow notes
36
- * when `siteConfig.feed.includeFlows` is enabled. Results are sorted by date
37
- * descending and capped at `siteConfig.feed.maxItems` (0 = unlimited).
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
- const postItems: FeedItem[] = getAllPosts().map((post) => ({
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
- let items: FeedItem[] = postItems;
54
-
55
- if (includeFlows) {
56
- const flowItems: FeedItem[] = getAllFlows().map((flow) => ({
57
- title: flow.title,
58
- url: `${baseUrl}${getFlowUrl(flow.slug)}`,
59
- date: new Date(flow.date),
60
- excerpt: flow.excerpt,
61
- content: markdownToHtml(flow.content),
62
- tags: flow.tags || [],
63
- }));
64
- items = [...postItems, ...flowItems].sort((a, b) => b.date.getTime() - a.date.getTime());
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
87
+ .replace(/"/g, '&quot;').replace(/'/g, '&apos;');
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
+ }
@@ -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
- const post = getPostBySlug(item.post);
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
- if (seen.has(post.slug)) return false;
898
- seen.add(post.slug);
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 ?? date, // fall back to date string if no title in frontmatter
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 + 2 from nextjs-deep-dive
112
- expect(all["modern-web-dev"].length).toBe(4);
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
+ });