@hutusi/amytis 1.9.0 → 1.11.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 (143) hide show
  1. package/.entire/settings.json +4 -0
  2. package/AGENTS.md +13 -7
  3. package/CHANGELOG.md +41 -0
  4. package/CLAUDE.md +30 -2
  5. package/README.md +84 -58
  6. package/README.zh.md +195 -0
  7. package/TODO.md +6 -4
  8. package/bun.lock +30 -4
  9. package/content/books/sample-book/images/digital-garden.svg +25 -0
  10. package/content/books/sample-book/index.mdx +1 -1
  11. package/content/books/sample-book/introduction.mdx +2 -0
  12. package/content/posts/2026-01-21-kitchen-sink/index.mdx +26 -1
  13. package/content/posts/legacy-markdown.md +0 -2
  14. package/content/posts/markdown-features.mdx +1 -1
  15. package/content/posts/multilingual-test.mdx +0 -1
  16. package/content/posts/syntax-highlighting-showcase.mdx +1 -0
  17. package/content/posts/understanding-react-hooks.mdx +1 -0
  18. package/content/series/ai-nexus-weekly/index.mdx +1 -1
  19. package/content/series/digital-garden/01-philosophy.mdx +2 -0
  20. package/content/series/digital-garden/index.mdx +1 -1
  21. package/content/series/markdown-showcase/index.mdx +1 -1
  22. package/content/series/markdown-showcase/syntax-highlighting.mdx +2 -0
  23. package/content/series/nextjs-deep-dive/01-getting-started.mdx +2 -1
  24. package/content/series/nextjs-deep-dive/index.mdx +1 -1
  25. package/content/subscribe.mdx +27 -0
  26. package/docs/ARCHITECTURE.md +85 -105
  27. package/docs/CONTRIBUTING.md +11 -3
  28. package/docs/DIGITAL_GARDEN.md +11 -9
  29. package/docs/deployment.md +31 -13
  30. package/eslint.config.mjs +2 -0
  31. package/package.json +13 -4
  32. package/packages/create-amytis/package.json +13 -0
  33. package/packages/create-amytis/src/index.test.ts +187 -0
  34. package/packages/create-amytis/src/index.ts +230 -0
  35. package/packages/create-amytis/tsconfig.json +14 -0
  36. package/public/images/amytis-screenshot.jpg +0 -0
  37. package/public/images/antelope-canyon.jpg +0 -0
  38. package/public/images/avatar.jpg +0 -0
  39. package/public/images/cappadocia.jpg +0 -0
  40. package/public/images/flowers.jpg +0 -0
  41. package/public/images/galaxy.jpg +0 -0
  42. package/public/images/lake.jpg +0 -0
  43. package/public/images/mountains.jpg +0 -0
  44. package/public/images/vibrant-waves.avif +0 -0
  45. package/public/images/wechat-qr.jpg +0 -0
  46. package/public/next-image-export-optimizer-hashes.json +72 -2
  47. package/scripts/deploy.ts +77 -0
  48. package/site.config.example.ts +268 -0
  49. package/site.config.ts +82 -10
  50. package/src/app/[slug]/[postSlug]/page.tsx +161 -0
  51. package/src/app/[slug]/page/[page]/page.tsx +202 -0
  52. package/src/app/[slug]/page.tsx +162 -7
  53. package/src/app/archive/page.tsx +5 -8
  54. package/src/app/books/[slug]/[chapter]/page.tsx +45 -3
  55. package/src/app/books/[slug]/page.tsx +16 -2
  56. package/src/app/books/page.tsx +12 -8
  57. package/src/app/feed.atom/route.ts +62 -0
  58. package/src/app/feed.xml/route.ts +45 -20
  59. package/src/app/flows/[year]/[month]/[day]/page.tsx +9 -1
  60. package/src/app/flows/[year]/page.tsx +1 -1
  61. package/src/app/flows/page/[page]/page.tsx +2 -0
  62. package/src/app/flows/page.tsx +4 -0
  63. package/src/app/globals.css +9 -3
  64. package/src/app/layout.tsx +22 -1
  65. package/src/app/notes/[slug]/page.tsx +2 -3
  66. package/src/app/page.tsx +86 -8
  67. package/src/app/posts/[slug]/page.tsx +33 -7
  68. package/src/app/posts/page/[page]/page.tsx +9 -5
  69. package/src/app/posts/page.tsx +13 -7
  70. package/src/app/series/[slug]/page/[page]/page.tsx +8 -25
  71. package/src/app/series/[slug]/page.tsx +21 -19
  72. package/src/app/series/page.tsx +14 -28
  73. package/src/app/sitemap.ts +2 -1
  74. package/src/app/tags/[tag]/page.tsx +1 -1
  75. package/src/app/tags/page.tsx +1 -1
  76. package/src/components/Analytics.tsx +34 -36
  77. package/src/components/AuthorCard.tsx +56 -14
  78. package/src/components/BookMobileNav.tsx +8 -34
  79. package/src/components/BookSidebar.tsx +17 -107
  80. package/src/components/CodeBlock.test.tsx +19 -0
  81. package/src/components/CodeBlock.tsx +20 -16
  82. package/src/components/Comments.tsx +4 -3
  83. package/src/components/CoverImage.tsx +11 -3
  84. package/src/components/CuratedSeriesSection.tsx +17 -15
  85. package/src/components/ExternalLinks.tsx +1 -1
  86. package/src/components/FeaturedStoriesSection.tsx +124 -58
  87. package/src/components/FlowCalendarSidebar.tsx +10 -7
  88. package/src/components/FlowContent.tsx +33 -5
  89. package/src/components/Footer.tsx +28 -6
  90. package/src/components/Hero.tsx +44 -92
  91. package/src/components/InlineBookToc.tsx +56 -0
  92. package/src/components/KnowledgeGraph.tsx +26 -0
  93. package/src/components/LanguageProvider.tsx +7 -2
  94. package/src/components/LanguageSwitch.tsx +1 -1
  95. package/src/components/LatestWritingSection.tsx +12 -22
  96. package/src/components/MarkdownRenderer.test.tsx +41 -0
  97. package/src/components/MarkdownRenderer.tsx +43 -8
  98. package/src/components/Navbar.tsx +24 -15
  99. package/src/components/NoteSidebar.tsx +6 -76
  100. package/src/components/Pagination.tsx +2 -2
  101. package/src/components/PostCard.tsx +2 -1
  102. package/src/components/PostList.tsx +81 -62
  103. package/src/components/PostNavigation.tsx +8 -7
  104. package/src/components/PostSidebar.tsx +12 -107
  105. package/src/components/PrevNextNav.tsx +63 -0
  106. package/src/components/RecentNotesSection.tsx +2 -2
  107. package/src/components/RelatedPosts.tsx +3 -2
  108. package/src/components/RssFeedWidget.tsx +55 -0
  109. package/src/components/SectionLabel.tsx +16 -0
  110. package/src/components/SelectedBooksSection.tsx +12 -15
  111. package/src/components/SeriesCatalog.tsx +74 -69
  112. package/src/components/SeriesList.tsx +11 -36
  113. package/src/components/SeriesSidebar.tsx +4 -2
  114. package/src/components/TagContentTabs.tsx +17 -12
  115. package/src/components/TagPageHeader.tsx +12 -2
  116. package/src/components/TagSidebar.tsx +3 -2
  117. package/src/components/TagsIndexClient.tsx +1 -1
  118. package/src/components/TocPanel.tsx +67 -0
  119. package/src/hooks/useActiveHeading.ts +32 -0
  120. package/src/hooks/useSidebarAutoScroll.ts +19 -0
  121. package/src/i18n/translations.ts +26 -4
  122. package/src/layouts/BookLayout.tsx +10 -40
  123. package/src/layouts/PostLayout.tsx +37 -26
  124. package/src/layouts/SimpleLayout.tsx +5 -5
  125. package/src/lib/feed-utils.ts +68 -0
  126. package/src/lib/format-utils.test.ts +27 -0
  127. package/src/lib/format-utils.ts +3 -0
  128. package/src/lib/image-utils.ts +12 -0
  129. package/src/lib/json-ld.ts +236 -0
  130. package/src/lib/markdown.ts +62 -7
  131. package/src/lib/rehype-image-metadata.test.ts +124 -0
  132. package/src/lib/rehype-image-metadata.ts +21 -5
  133. package/src/lib/scroll-utils.ts +11 -0
  134. package/src/lib/shuffle.ts +21 -0
  135. package/src/lib/urls.ts +51 -0
  136. package/tests/e2e/navigation.test.ts +15 -1
  137. package/tests/integration/books.test.ts +61 -0
  138. package/tests/integration/feed-utils.test.ts +145 -0
  139. package/tests/integration/series.test.ts +9 -0
  140. package/tests/unit/static-params.test.ts +59 -2
  141. package/src/app/subscribe/page.tsx +0 -17
  142. package/src/components/SubscribePage.tsx +0 -298
  143. /package/public/{screenshot.png → images/screenshot.png} +0 -0
@@ -0,0 +1,68 @@
1
+ import { unified } from 'unified';
2
+ import remarkParse from 'remark-parse';
3
+ import remarkGfm from 'remark-gfm';
4
+ import remarkRehype from 'remark-rehype';
5
+ import rehypeRaw from 'rehype-raw';
6
+ import rehypeStringify from 'rehype-stringify';
7
+ import { getAllPosts, getAllFlows } from './markdown';
8
+ import { siteConfig } from '../../site.config';
9
+ import { getPostUrl, getFlowUrl } from './urls';
10
+
11
+ export interface FeedItem {
12
+ title: string;
13
+ url: string;
14
+ date: Date;
15
+ excerpt: string;
16
+ /** Rendered HTML content for use in content:encoded / Atom <content> */
17
+ content: string;
18
+ tags: string[];
19
+ authors?: string[];
20
+ }
21
+
22
+ function markdownToHtml(markdown: string): string {
23
+ const result = unified()
24
+ .use(remarkParse)
25
+ .use(remarkGfm)
26
+ .use(remarkRehype, { allowDangerousHtml: true })
27
+ .use(rehypeRaw)
28
+ .use(rehypeStringify)
29
+ .processSync(markdown);
30
+ return String(result);
31
+ }
32
+
33
+ /**
34
+ * 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
+ */
39
+ export function getFeedItems(): FeedItem[] {
40
+ const { maxItems, includeFlows } = siteConfig.feed;
41
+ const baseUrl = siteConfig.baseUrl.replace(/\/+$/, '');
42
+
43
+ const postItems: FeedItem[] = getAllPosts().map((post) => ({
44
+ title: post.title,
45
+ url: `${baseUrl}${getPostUrl(post)}`,
46
+ date: new Date(post.date),
47
+ excerpt: post.excerpt,
48
+ content: markdownToHtml(post.content),
49
+ tags: post.tags || [],
50
+ authors: post.authors,
51
+ }));
52
+
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());
65
+ }
66
+
67
+ return maxItems > 0 ? items.slice(0, maxItems) : items;
68
+ }
@@ -0,0 +1,27 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { padNumber } from './format-utils';
3
+
4
+ describe('padNumber', () => {
5
+ test('pads single digit to width 2 by default', () => {
6
+ expect(padNumber(1)).toBe('01');
7
+ expect(padNumber(9)).toBe('09');
8
+ });
9
+
10
+ test('does not pad numbers already at default width', () => {
11
+ expect(padNumber(10)).toBe('10');
12
+ expect(padNumber(99)).toBe('99');
13
+ });
14
+
15
+ test('does not truncate numbers wider than the default width', () => {
16
+ expect(padNumber(100)).toBe('100');
17
+ });
18
+
19
+ test('pads zero', () => {
20
+ expect(padNumber(0)).toBe('00');
21
+ });
22
+
23
+ test('respects custom width', () => {
24
+ expect(padNumber(5, 3)).toBe('005');
25
+ expect(padNumber(42, 4)).toBe('0042');
26
+ });
27
+ });
@@ -0,0 +1,3 @@
1
+ export function padNumber(n: number, width = 2): string {
2
+ return String(n).padStart(width, '0');
3
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Prepend a CDN base URL to a local image path.
3
+ * External URLs (http/https) and special values (text:, data:) are returned as-is.
4
+ */
5
+ export function getCdnImageUrl(src: string, cdnBaseUrl: string): string {
6
+ if (!cdnBaseUrl || !src) return src;
7
+ if (src.startsWith('http') || src.startsWith('//')) return src;
8
+ if (src.startsWith('text:') || src.startsWith('data:')) return src;
9
+ const base = cdnBaseUrl.replace(/\/$/, '');
10
+ const path = src.startsWith('/') ? src : `/${src}`;
11
+ return `${base}${path}`;
12
+ }
@@ -0,0 +1,236 @@
1
+ // ── Schema shape types ────────────────────────────────────────────────────────
2
+
3
+ interface SchemaOrganization {
4
+ '@type': 'Organization';
5
+ name: string;
6
+ url: string;
7
+ }
8
+
9
+ interface SchemaPerson {
10
+ '@type': 'Person';
11
+ name: string;
12
+ }
13
+
14
+ interface SchemaImageObject {
15
+ '@type': 'ImageObject';
16
+ url: string;
17
+ }
18
+
19
+ interface SchemaBlogPosting {
20
+ '@type': 'BlogPosting';
21
+ headline: string;
22
+ description: string | undefined;
23
+ url: string;
24
+ datePublished: string;
25
+ dateModified: string;
26
+ author: SchemaPerson[];
27
+ publisher: SchemaOrganization;
28
+ image: SchemaImageObject;
29
+ keywords: string | undefined;
30
+ mainEntityOfPage: { '@type': 'WebPage'; '@id': string };
31
+ }
32
+
33
+ interface SchemaBook {
34
+ '@type': 'Book';
35
+ name: string;
36
+ description: string | undefined;
37
+ url: string;
38
+ datePublished: string;
39
+ author: SchemaPerson[];
40
+ publisher: SchemaOrganization;
41
+ image: SchemaImageObject | undefined;
42
+ }
43
+
44
+ interface SchemaArticle {
45
+ '@type': 'Article';
46
+ headline: string;
47
+ description: string | undefined;
48
+ url: string;
49
+ datePublished: string;
50
+ author: SchemaPerson[];
51
+ publisher: SchemaOrganization;
52
+ isPartOf: { '@type': 'Book'; '@id': string; name: string };
53
+ }
54
+
55
+ interface SchemaWebSite {
56
+ '@type': 'WebSite';
57
+ name: string;
58
+ url: string;
59
+ description?: string;
60
+ }
61
+
62
+ type SchemaNode = SchemaBlogPosting | SchemaBook | SchemaArticle | SchemaWebSite;
63
+
64
+ interface SchemaGraph {
65
+ '@context': 'https://schema.org';
66
+ '@graph': SchemaNode[];
67
+ }
68
+
69
+ // ── Helpers ───────────────────────────────────────────────────────────────────
70
+
71
+ function buildPublisher(siteTitle: string, siteUrl: string): SchemaOrganization {
72
+ return { '@type': 'Organization', name: siteTitle, url: siteUrl };
73
+ }
74
+
75
+ function buildAuthors(names: string[]): SchemaPerson[] {
76
+ return names.map(name => ({ '@type': 'Person', name }));
77
+ }
78
+
79
+ /**
80
+ * Resolve an absolute image URL for structured data.
81
+ * Accepts external URLs (http/https) and site-relative paths (/books/..., /posts/...).
82
+ * Rejects relative paths (./images/...) and text placeholders (text:...).
83
+ */
84
+ export function resolveImageUrl(
85
+ coverImage: string | undefined,
86
+ defaultOgImage: string,
87
+ siteUrl: string,
88
+ ): string {
89
+ const toAbsolute = (url: string) =>
90
+ url.startsWith('http') ? url : `${siteUrl}${url.startsWith('/') ? url : `/${url}`}`;
91
+
92
+ if (coverImage && !coverImage.startsWith('text:') && !coverImage.startsWith('./')) {
93
+ return toAbsolute(coverImage);
94
+ }
95
+ return toAbsolute(defaultOgImage);
96
+ }
97
+
98
+ /**
99
+ * Serialize a JSON-LD graph to a string safe for injection into a <script> tag.
100
+ * JSON.stringify does not escape '<', so a value containing '</script>' could
101
+ * break the script block. Replacing '<' with '\u003c' prevents this.
102
+ */
103
+ export function serializeJsonLd(schema: SchemaGraph): string {
104
+ return JSON.stringify(schema).replace(/</g, '\\u003c');
105
+ }
106
+
107
+ function wrapGraph(nodes: SchemaNode[]): SchemaGraph {
108
+ return { '@context': 'https://schema.org', '@graph': nodes };
109
+ }
110
+
111
+ export interface WebsiteJsonLdParams {
112
+ siteTitle: string;
113
+ siteUrl: string;
114
+ description?: string;
115
+ }
116
+
117
+ // ── Public param types ────────────────────────────────────────────────────────
118
+
119
+ export interface PostJsonLdParams {
120
+ post: {
121
+ title: string;
122
+ excerpt: string;
123
+ date: string;
124
+ authors: string[];
125
+ tags: string[];
126
+ coverImage?: string;
127
+ };
128
+ postUrl: string;
129
+ siteTitle: string;
130
+ siteUrl: string;
131
+ defaultOgImage: string;
132
+ }
133
+
134
+ export interface BookJsonLdParams {
135
+ book: {
136
+ title: string;
137
+ excerpt?: string;
138
+ date: string;
139
+ authors: string[];
140
+ coverImage?: string;
141
+ };
142
+ bookUrl: string;
143
+ siteTitle: string;
144
+ siteUrl: string;
145
+ defaultOgImage: string;
146
+ }
147
+
148
+ export interface BookChapterJsonLdParams {
149
+ chapter: {
150
+ title: string;
151
+ excerpt?: string;
152
+ };
153
+ book: {
154
+ title: string;
155
+ date: string;
156
+ authors: string[];
157
+ };
158
+ chapterUrl: string;
159
+ bookUrl: string;
160
+ siteTitle: string;
161
+ siteUrl: string;
162
+ }
163
+
164
+ // ── Builders ──────────────────────────────────────────────────────────────────
165
+
166
+ export function buildWebsiteJsonLd(params: WebsiteJsonLdParams): SchemaGraph {
167
+ const { siteTitle, siteUrl, description } = params;
168
+ const base = siteUrl.replace(/\/+$/, '');
169
+
170
+ const node: SchemaWebSite = {
171
+ '@type': 'WebSite',
172
+ name: siteTitle,
173
+ url: base,
174
+ description: description || undefined,
175
+ };
176
+
177
+ return wrapGraph([node]);
178
+ }
179
+
180
+ export function buildPostJsonLd(params: PostJsonLdParams): SchemaGraph {
181
+ const { post, postUrl, siteTitle, siteUrl, defaultOgImage } = params;
182
+ const base = siteUrl.replace(/\/+$/, '');
183
+
184
+ const node: SchemaBlogPosting = {
185
+ '@type': 'BlogPosting',
186
+ headline: post.title,
187
+ description: post.excerpt || undefined,
188
+ url: postUrl,
189
+ datePublished: post.date,
190
+ dateModified: post.date,
191
+ author: buildAuthors(post.authors),
192
+ publisher: buildPublisher(siteTitle, base),
193
+ image: { '@type': 'ImageObject', url: resolveImageUrl(post.coverImage, defaultOgImage, base) },
194
+ keywords: post.tags.length > 0 ? post.tags.join(', ') : undefined,
195
+ mainEntityOfPage: { '@type': 'WebPage', '@id': postUrl },
196
+ };
197
+
198
+ return wrapGraph([node]);
199
+ }
200
+
201
+ export function buildBookJsonLd(params: BookJsonLdParams): SchemaGraph {
202
+ const { book, bookUrl, siteTitle, siteUrl, defaultOgImage } = params;
203
+ const base = siteUrl.replace(/\/+$/, '');
204
+ const imageUrl = resolveImageUrl(book.coverImage, defaultOgImage, base);
205
+
206
+ const node: SchemaBook = {
207
+ '@type': 'Book',
208
+ name: book.title,
209
+ description: book.excerpt || undefined,
210
+ url: bookUrl,
211
+ datePublished: book.date,
212
+ author: buildAuthors(book.authors),
213
+ publisher: buildPublisher(siteTitle, base),
214
+ image: imageUrl ? { '@type': 'ImageObject', url: imageUrl } : undefined,
215
+ };
216
+
217
+ return wrapGraph([node]);
218
+ }
219
+
220
+ export function buildBookChapterJsonLd(params: BookChapterJsonLdParams): SchemaGraph {
221
+ const { chapter, book, chapterUrl, bookUrl, siteTitle, siteUrl } = params;
222
+ const base = siteUrl.replace(/\/+$/, '');
223
+
224
+ const node: SchemaArticle = {
225
+ '@type': 'Article',
226
+ headline: chapter.title,
227
+ description: chapter.excerpt || undefined,
228
+ url: chapterUrl,
229
+ datePublished: book.date,
230
+ author: buildAuthors(book.authors),
231
+ publisher: buildPublisher(siteTitle, base),
232
+ isPartOf: { '@type': 'Book', '@id': bookUrl, name: book.title },
233
+ };
234
+
235
+ return wrapGraph([node]);
236
+ }
@@ -4,6 +4,7 @@ import matter from 'gray-matter';
4
4
  import { siteConfig } from '../../site.config';
5
5
  import GithubSlugger from 'github-slugger';
6
6
  import { z } from 'zod';
7
+ import { getPostUrl } from './urls';
7
8
 
8
9
  const contentDirectory = path.join(process.cwd(), 'content', 'posts');
9
10
  const pagesDirectory = path.join(process.cwd(), 'content');
@@ -20,6 +21,7 @@ const ExternalLinkSchema = z.object({
20
21
  const PostSchema = z.object({
21
22
  title: z.string(),
22
23
  date: z.union([z.string(), z.date()]).transform(val => new Date(val).toISOString().split('T')[0]).optional(),
24
+ subtitle: z.string().optional(),
23
25
  excerpt: z.string().optional(),
24
26
  category: z.string().optional().default('Uncategorized'),
25
27
  tags: z.array(z.string()).optional().default([]),
@@ -31,6 +33,7 @@ const PostSchema = z.object({
31
33
  sort: z.enum(['date-desc', 'date-asc', 'manual']).optional().default('date-desc'),
32
34
  posts: z.array(z.string()).optional(),
33
35
  featured: z.boolean().optional().default(false),
36
+ pinned: z.boolean().optional().default(false),
34
37
  draft: z.boolean().optional().default(false),
35
38
  latex: z.boolean().optional().default(false),
36
39
  toc: z.boolean().optional().default(true),
@@ -51,6 +54,7 @@ export interface ExternalLink {
51
54
  export interface PostData {
52
55
  slug: string;
53
56
  title: string;
57
+ subtitle?: string;
54
58
  date: string;
55
59
  excerpt: string;
56
60
  category: string;
@@ -58,10 +62,12 @@ export interface PostData {
58
62
  authors: string[];
59
63
  layout?: string;
60
64
  series?: string;
65
+ seriesTitle?: string;
61
66
  coverImage?: string;
62
67
  sort?: 'date-desc' | 'date-asc' | 'manual';
63
68
  posts?: string[];
64
69
  featured?: boolean;
70
+ pinned?: boolean;
65
71
  draft?: boolean;
66
72
  latex?: boolean;
67
73
  toc?: boolean;
@@ -149,6 +155,38 @@ export function getSeriesAuthors(seriesSlug: string): string[] | null {
149
155
  return null;
150
156
  }
151
157
 
158
+ /**
159
+ * Resolve display authors for a series: explicit series authors first,
160
+ * then top contributors aggregated from the series' posts.
161
+ */
162
+ export function resolveSeriesAuthors(slug: string, posts: PostData[]): string[] {
163
+ const explicit = getSeriesAuthors(slug);
164
+ if (explicit) return explicit;
165
+ if (posts.length === 0) return [];
166
+ const counts = new Map<string, number>();
167
+ for (const post of posts) {
168
+ for (const author of post.authors) {
169
+ counts.set(author, (counts.get(author) || 0) + 1);
170
+ }
171
+ }
172
+ return [...counts.entries()]
173
+ .sort((a, b) => b[1] - a[1])
174
+ .map(([name]) => name);
175
+ }
176
+
177
+ function getSeriesTitle(slug: string): string | undefined {
178
+ if (!fs.existsSync(seriesDirectory)) return undefined;
179
+ const indexPathMdx = path.join(seriesDirectory, slug, 'index.mdx');
180
+ const indexPathMd = path.join(seriesDirectory, slug, 'index.md');
181
+ let fullPath = '';
182
+ if (fs.existsSync(indexPathMdx)) fullPath = indexPathMdx;
183
+ else if (fs.existsSync(indexPathMd)) fullPath = indexPathMd;
184
+ else return undefined;
185
+ const { data } = matter(fs.readFileSync(fullPath, 'utf8'));
186
+ if (data.draft === true) return undefined;
187
+ return typeof data.title === 'string' ? data.title : undefined;
188
+ }
189
+
152
190
  function parseMarkdownFile(fullPath: string, slug: string, dateFromFileName?: string, seriesName?: string): PostData {
153
191
  const fileContents = fs.readFileSync(fullPath, 'utf8');
154
192
  const { data: rawData, content } = matter(fileContents);
@@ -162,6 +200,7 @@ function parseMarkdownFile(fullPath: string, slug: string, dateFromFileName?: st
162
200
 
163
201
  const contentWithoutH1 = content.replace(/^\s*#\s+[^\n]+/, '').trim();
164
202
 
203
+ const effectiveSeriesSlug = data.series || seriesName;
165
204
  let authors: string[] = [];
166
205
  if (data.authors && Array.isArray(data.authors)) {
167
206
  authors = data.authors;
@@ -169,15 +208,17 @@ function parseMarkdownFile(fullPath: string, slug: string, dateFromFileName?: st
169
208
  authors = [data.author];
170
209
  } else {
171
210
  // Inherit from series if this post belongs to one
172
- const seriesSlug = data.series || seriesName;
173
- if (seriesSlug) {
174
- const seriesAuthors = getSeriesAuthors(seriesSlug);
211
+ if (effectiveSeriesSlug) {
212
+ const seriesAuthors = getSeriesAuthors(effectiveSeriesSlug);
175
213
  if (seriesAuthors) {
176
214
  authors = seriesAuthors;
177
215
  }
178
216
  }
179
217
  if (authors.length === 0) {
180
- authors = ['Amytis'];
218
+ const defaultAuthors = siteConfig.posts?.authors?.default;
219
+ if (defaultAuthors && defaultAuthors.length > 0) {
220
+ authors = defaultAuthors;
221
+ }
181
222
  }
182
223
  }
183
224
 
@@ -199,17 +240,20 @@ function parseMarkdownFile(fullPath: string, slug: string, dateFromFileName?: st
199
240
  return {
200
241
  slug,
201
242
  title: data.title,
243
+ subtitle: data.subtitle,
202
244
  date,
203
245
  excerpt,
204
246
  category: data.category,
205
247
  tags: data.tags,
206
248
  authors,
207
249
  layout: data.layout,
208
- series: data.series || seriesName,
250
+ series: effectiveSeriesSlug,
251
+ seriesTitle: effectiveSeriesSlug ? getSeriesTitle(effectiveSeriesSlug) : undefined,
209
252
  coverImage,
210
253
  sort: data.sort,
211
254
  posts: data.posts,
212
255
  featured: data.featured,
256
+ pinned: data.pinned,
213
257
  draft: data.draft,
214
258
  latex: data.latex,
215
259
  toc: data.toc,
@@ -350,6 +394,17 @@ export function getAllPosts(): PostData[] {
350
394
  .sort((a, b) => (a.date < b.date ? 1 : -1));
351
395
  }
352
396
 
397
+ /**
398
+ * Returns posts for the main listing pages, honouring posts.excludeFromListing.
399
+ * Use this instead of getAllPosts() on any listing/pagination page.
400
+ * Individual post routes and series pages still use getAllPosts() directly.
401
+ */
402
+ export function getListingPosts(): PostData[] {
403
+ const excluded = new Set(siteConfig.posts?.excludeFromListing ?? []);
404
+ if (excluded.size === 0) return getAllPosts();
405
+ return getAllPosts().filter(p => !p.series || !excluded.has(p.series));
406
+ }
407
+
353
408
  function findPostFile(name: string, targetSlug: string): PostData | null {
354
409
  // Check standard posts
355
410
  let fullPath = path.join(contentDirectory, `${name}.mdx`);
@@ -1304,7 +1359,7 @@ export function buildSlugRegistry(): Map<string, SlugRegistryEntry> {
1304
1359
  const map = new Map<string, SlugRegistryEntry>();
1305
1360
 
1306
1361
  getAllPosts().forEach(p =>
1307
- map.set(p.slug, { url: `/posts/${p.slug}`, type: 'post', title: p.title })
1362
+ map.set(p.slug, { url: getPostUrl(p), type: 'post', title: p.title })
1308
1363
  );
1309
1364
 
1310
1365
  getAllFlows().forEach(f =>
@@ -1395,7 +1450,7 @@ function buildBacklinkIndex(): Map<string, BacklinkSource[]> {
1395
1450
  }
1396
1451
  };
1397
1452
 
1398
- getAllPosts().forEach(p => addBacklinks(p.content, p.slug, p.title, 'post', `/posts/${p.slug}`));
1453
+ getAllPosts().forEach(p => addBacklinks(p.content, p.slug, p.title, 'post', getPostUrl(p)));
1399
1454
  getAllNotes().forEach(n => addBacklinks(n.content, n.slug, n.title, 'note', `/notes/${n.slug}`));
1400
1455
  getAllFlows().forEach(f => addBacklinks(f.content, f.slug, f.title, 'flow', `/flows/${f.slug}`));
1401
1456
 
@@ -0,0 +1,124 @@
1
+ import { describe, test, expect } from 'bun:test';
2
+ import rehypeImageMetadata from './rehype-image-metadata';
3
+ import type { Root, Element } from 'hast';
4
+
5
+ /** Build a minimal HAST tree containing a single <img> node. */
6
+ function makeTree(src: string): Root {
7
+ return {
8
+ type: 'root',
9
+ children: [
10
+ {
11
+ type: 'element',
12
+ tagName: 'img',
13
+ properties: { src },
14
+ children: [],
15
+ } satisfies Element,
16
+ ],
17
+ };
18
+ }
19
+
20
+ function imgSrc(tree: Root): unknown {
21
+ return (tree.children[0] as Element).properties.src;
22
+ }
23
+
24
+ describe('rehype-image-metadata', () => {
25
+ describe('relative paths (./)', () => {
26
+ test('resolves to posts public path when slug is posts/{slug}', () => {
27
+ const tree = makeTree('./images/photo.png');
28
+ rehypeImageMetadata({ slug: 'posts/my-post' })(tree);
29
+ expect(imgSrc(tree)).toBe('/posts/my-post/images/photo.png');
30
+ });
31
+
32
+ test('resolves to books public path when slug is books/{slug}', () => {
33
+ const tree = makeTree('./images/cover.jpg');
34
+ rehypeImageMetadata({ slug: 'books/my-book' })(tree);
35
+ expect(imgSrc(tree)).toBe('/books/my-book/images/cover.jpg');
36
+ });
37
+
38
+ test('resolves to flows public path when slug is flows/{year}/{month}/{day}', () => {
39
+ const tree = makeTree('./images/snap.jpg');
40
+ rehypeImageMetadata({ slug: 'flows/2026/01/15' })(tree);
41
+ expect(imgSrc(tree)).toBe('/flows/2026/01/15/images/snap.jpg');
42
+ });
43
+
44
+ test('resolves to notes public path when slug is notes/{slug}', () => {
45
+ const tree = makeTree('./images/diagram.svg');
46
+ rehypeImageMetadata({ slug: 'notes/my-note' })(tree);
47
+ expect(imgSrc(tree)).toBe('/notes/my-note/images/diagram.svg');
48
+ });
49
+
50
+ test('does not modify src when no slug is provided', () => {
51
+ const tree = makeTree('./images/photo.png');
52
+ rehypeImageMetadata({})(tree);
53
+ expect(imgSrc(tree)).toBe('./images/photo.png');
54
+ });
55
+
56
+ test('handles nested relative paths correctly', () => {
57
+ const tree = makeTree('./assets/sub/image.webp');
58
+ rehypeImageMetadata({ slug: 'posts/my-post' })(tree);
59
+ expect(imgSrc(tree)).toBe('/posts/my-post/assets/sub/image.webp');
60
+ });
61
+ });
62
+
63
+ describe('absolute paths (/)', () => {
64
+ test('keeps absolute paths as-is', () => {
65
+ const tree = makeTree('/static/logo.png');
66
+ rehypeImageMetadata({ slug: 'posts/my-post' })(tree);
67
+ expect(imgSrc(tree)).toBe('/static/logo.png');
68
+ });
69
+
70
+ test('resolves absolute path without slug', () => {
71
+ const tree = makeTree('/images/banner.png');
72
+ rehypeImageMetadata({})(tree);
73
+ expect(imgSrc(tree)).toBe('/images/banner.png');
74
+ });
75
+ });
76
+
77
+ describe('external URLs', () => {
78
+ test('does not touch http:// URLs', () => {
79
+ const tree = makeTree('https://example.com/image.png');
80
+ rehypeImageMetadata({ slug: 'posts/my-post' })(tree);
81
+ expect(imgSrc(tree)).toBe('https://example.com/image.png');
82
+ });
83
+
84
+ test('does not touch // protocol-relative URLs', () => {
85
+ const tree = makeTree('//cdn.example.com/image.png');
86
+ rehypeImageMetadata({ slug: 'posts/my-post' })(tree);
87
+ expect(imgSrc(tree)).toBe('//cdn.example.com/image.png');
88
+ });
89
+ });
90
+
91
+ describe('bare relative paths (no prefix)', () => {
92
+ test('resolves bare relative paths against slug', () => {
93
+ const tree = makeTree('images/photo.png');
94
+ rehypeImageMetadata({ slug: 'posts/my-post' })(tree);
95
+ expect(imgSrc(tree)).toBe('/posts/my-post/images/photo.png');
96
+ });
97
+
98
+ test('keeps bare relative paths when no slug is provided', () => {
99
+ const tree = makeTree('images/photo.png');
100
+ rehypeImageMetadata({})(tree);
101
+ expect(imgSrc(tree)).toBe('images/photo.png');
102
+ });
103
+ });
104
+
105
+ describe('CDN prefix', () => {
106
+ test('prepends CDN base URL to resolved relative path', () => {
107
+ const tree = makeTree('./images/photo.png');
108
+ rehypeImageMetadata({ slug: 'posts/my-post', cdnBaseUrl: 'https://cdn.example.com' })(tree);
109
+ expect(imgSrc(tree)).toBe('https://cdn.example.com/posts/my-post/images/photo.png');
110
+ });
111
+
112
+ test('prepends CDN base URL to absolute path', () => {
113
+ const tree = makeTree('/static/logo.png');
114
+ rehypeImageMetadata({ cdnBaseUrl: 'https://cdn.example.com' })(tree);
115
+ expect(imgSrc(tree)).toBe('https://cdn.example.com/static/logo.png');
116
+ });
117
+
118
+ test('does not prepend CDN to external URLs', () => {
119
+ const tree = makeTree('https://external.com/img.png');
120
+ rehypeImageMetadata({ cdnBaseUrl: 'https://cdn.example.com' })(tree);
121
+ expect(imgSrc(tree)).toBe('https://external.com/img.png');
122
+ });
123
+ });
124
+ });