@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.
- package/.entire/settings.json +4 -0
- package/AGENTS.md +13 -7
- package/CHANGELOG.md +41 -0
- package/CLAUDE.md +30 -2
- package/README.md +84 -58
- package/README.zh.md +195 -0
- package/TODO.md +6 -4
- package/bun.lock +30 -4
- package/content/books/sample-book/images/digital-garden.svg +25 -0
- package/content/books/sample-book/index.mdx +1 -1
- package/content/books/sample-book/introduction.mdx +2 -0
- package/content/posts/2026-01-21-kitchen-sink/index.mdx +26 -1
- package/content/posts/legacy-markdown.md +0 -2
- package/content/posts/markdown-features.mdx +1 -1
- package/content/posts/multilingual-test.mdx +0 -1
- package/content/posts/syntax-highlighting-showcase.mdx +1 -0
- package/content/posts/understanding-react-hooks.mdx +1 -0
- package/content/series/ai-nexus-weekly/index.mdx +1 -1
- package/content/series/digital-garden/01-philosophy.mdx +2 -0
- package/content/series/digital-garden/index.mdx +1 -1
- package/content/series/markdown-showcase/index.mdx +1 -1
- package/content/series/markdown-showcase/syntax-highlighting.mdx +2 -0
- package/content/series/nextjs-deep-dive/01-getting-started.mdx +2 -1
- package/content/series/nextjs-deep-dive/index.mdx +1 -1
- package/content/subscribe.mdx +27 -0
- package/docs/ARCHITECTURE.md +85 -105
- package/docs/CONTRIBUTING.md +11 -3
- package/docs/DIGITAL_GARDEN.md +11 -9
- package/docs/deployment.md +31 -13
- package/eslint.config.mjs +2 -0
- package/package.json +13 -4
- package/packages/create-amytis/package.json +13 -0
- package/packages/create-amytis/src/index.test.ts +187 -0
- package/packages/create-amytis/src/index.ts +230 -0
- package/packages/create-amytis/tsconfig.json +14 -0
- package/public/images/amytis-screenshot.jpg +0 -0
- package/public/images/antelope-canyon.jpg +0 -0
- package/public/images/avatar.jpg +0 -0
- package/public/images/cappadocia.jpg +0 -0
- package/public/images/flowers.jpg +0 -0
- package/public/images/galaxy.jpg +0 -0
- package/public/images/lake.jpg +0 -0
- package/public/images/mountains.jpg +0 -0
- package/public/images/vibrant-waves.avif +0 -0
- package/public/images/wechat-qr.jpg +0 -0
- package/public/next-image-export-optimizer-hashes.json +72 -2
- package/scripts/deploy.ts +77 -0
- package/site.config.example.ts +268 -0
- package/site.config.ts +82 -10
- package/src/app/[slug]/[postSlug]/page.tsx +161 -0
- package/src/app/[slug]/page/[page]/page.tsx +202 -0
- package/src/app/[slug]/page.tsx +162 -7
- package/src/app/archive/page.tsx +5 -8
- package/src/app/books/[slug]/[chapter]/page.tsx +45 -3
- package/src/app/books/[slug]/page.tsx +16 -2
- package/src/app/books/page.tsx +12 -8
- package/src/app/feed.atom/route.ts +62 -0
- package/src/app/feed.xml/route.ts +45 -20
- package/src/app/flows/[year]/[month]/[day]/page.tsx +9 -1
- package/src/app/flows/[year]/page.tsx +1 -1
- package/src/app/flows/page/[page]/page.tsx +2 -0
- package/src/app/flows/page.tsx +4 -0
- package/src/app/globals.css +9 -3
- package/src/app/layout.tsx +22 -1
- package/src/app/notes/[slug]/page.tsx +2 -3
- package/src/app/page.tsx +86 -8
- package/src/app/posts/[slug]/page.tsx +33 -7
- package/src/app/posts/page/[page]/page.tsx +9 -5
- package/src/app/posts/page.tsx +13 -7
- package/src/app/series/[slug]/page/[page]/page.tsx +8 -25
- package/src/app/series/[slug]/page.tsx +21 -19
- package/src/app/series/page.tsx +14 -28
- package/src/app/sitemap.ts +2 -1
- package/src/app/tags/[tag]/page.tsx +1 -1
- package/src/app/tags/page.tsx +1 -1
- package/src/components/Analytics.tsx +34 -36
- package/src/components/AuthorCard.tsx +56 -14
- package/src/components/BookMobileNav.tsx +8 -34
- package/src/components/BookSidebar.tsx +17 -107
- package/src/components/CodeBlock.test.tsx +19 -0
- package/src/components/CodeBlock.tsx +20 -16
- package/src/components/Comments.tsx +4 -3
- package/src/components/CoverImage.tsx +11 -3
- package/src/components/CuratedSeriesSection.tsx +17 -15
- package/src/components/ExternalLinks.tsx +1 -1
- package/src/components/FeaturedStoriesSection.tsx +124 -58
- package/src/components/FlowCalendarSidebar.tsx +10 -7
- package/src/components/FlowContent.tsx +33 -5
- package/src/components/Footer.tsx +28 -6
- package/src/components/Hero.tsx +44 -92
- package/src/components/InlineBookToc.tsx +56 -0
- package/src/components/KnowledgeGraph.tsx +26 -0
- package/src/components/LanguageProvider.tsx +7 -2
- package/src/components/LanguageSwitch.tsx +1 -1
- package/src/components/LatestWritingSection.tsx +12 -22
- package/src/components/MarkdownRenderer.test.tsx +41 -0
- package/src/components/MarkdownRenderer.tsx +43 -8
- package/src/components/Navbar.tsx +24 -15
- package/src/components/NoteSidebar.tsx +6 -76
- package/src/components/Pagination.tsx +2 -2
- package/src/components/PostCard.tsx +2 -1
- package/src/components/PostList.tsx +81 -62
- package/src/components/PostNavigation.tsx +8 -7
- package/src/components/PostSidebar.tsx +12 -107
- package/src/components/PrevNextNav.tsx +63 -0
- package/src/components/RecentNotesSection.tsx +2 -2
- package/src/components/RelatedPosts.tsx +3 -2
- package/src/components/RssFeedWidget.tsx +55 -0
- package/src/components/SectionLabel.tsx +16 -0
- package/src/components/SelectedBooksSection.tsx +12 -15
- package/src/components/SeriesCatalog.tsx +74 -69
- package/src/components/SeriesList.tsx +11 -36
- package/src/components/SeriesSidebar.tsx +4 -2
- package/src/components/TagContentTabs.tsx +17 -12
- package/src/components/TagPageHeader.tsx +12 -2
- package/src/components/TagSidebar.tsx +3 -2
- package/src/components/TagsIndexClient.tsx +1 -1
- package/src/components/TocPanel.tsx +67 -0
- package/src/hooks/useActiveHeading.ts +32 -0
- package/src/hooks/useSidebarAutoScroll.ts +19 -0
- package/src/i18n/translations.ts +26 -4
- package/src/layouts/BookLayout.tsx +10 -40
- package/src/layouts/PostLayout.tsx +37 -26
- package/src/layouts/SimpleLayout.tsx +5 -5
- package/src/lib/feed-utils.ts +68 -0
- package/src/lib/format-utils.test.ts +27 -0
- package/src/lib/format-utils.ts +3 -0
- package/src/lib/image-utils.ts +12 -0
- package/src/lib/json-ld.ts +236 -0
- package/src/lib/markdown.ts +62 -7
- package/src/lib/rehype-image-metadata.test.ts +124 -0
- package/src/lib/rehype-image-metadata.ts +21 -5
- package/src/lib/scroll-utils.ts +11 -0
- package/src/lib/shuffle.ts +21 -0
- package/src/lib/urls.ts +51 -0
- package/tests/e2e/navigation.test.ts +15 -1
- package/tests/integration/books.test.ts +61 -0
- package/tests/integration/feed-utils.test.ts +145 -0
- package/tests/integration/series.test.ts +9 -0
- package/tests/unit/static-params.test.ts +59 -2
- package/src/app/subscribe/page.tsx +0 -17
- package/src/components/SubscribePage.tsx +0 -298
- /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,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
|
+
}
|
package/src/lib/markdown.ts
CHANGED
|
@@ -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
|
-
|
|
173
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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',
|
|
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
|
+
});
|