@hutusi/amytis 1.5.6 → 1.7.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 +94 -0
- package/CLAUDE.md +3 -2
- package/GEMINI.md +13 -6
- package/README.md +1 -1
- package/TODO.md +21 -76
- package/bun.lock +18 -3
- package/content/about.mdx +1 -0
- package/content/about.zh.mdx +21 -0
- package/content/flows/2026/02/20.md +16 -0
- package/content/links.mdx +42 -0
- package/content/links.zh.mdx +41 -0
- package/content/posts/2026-02-20-i18n-routing-considerations.mdx +150 -0
- package/content/posts/multimedia-showcase/index.mdx +261 -0
- package/content/privacy.mdx +32 -0
- package/content/privacy.zh.mdx +32 -0
- package/docs/ARCHITECTURE.md +11 -2
- package/docs/CONTRIBUTING.md +4 -2
- package/docs/deployment.md +9 -1
- package/eslint.config.mjs +2 -0
- package/package.json +5 -4
- package/public/next-image-export-optimizer-hashes.json +0 -3
- package/scripts/copy-assets.ts +1 -1
- package/site.config.ts +126 -44
- package/src/app/[slug]/page.tsx +0 -10
- package/src/app/archive/page.tsx +38 -10
- package/src/app/books/[slug]/page.tsx +18 -0
- package/src/app/flows/[year]/[month]/[day]/page.tsx +21 -4
- package/src/app/layout.tsx +48 -21
- package/src/app/page.tsx +135 -72
- package/src/app/posts/[slug]/page.tsx +6 -12
- package/src/app/search.json/route.ts +4 -0
- package/src/app/series/[slug]/page.tsx +18 -0
- package/src/app/subscribe/page.tsx +17 -0
- package/src/app/tags/[tag]/page.tsx +9 -26
- package/src/app/tags/page.tsx +3 -8
- package/src/components/AuthorCard.tsx +43 -0
- package/src/components/Comments.tsx +20 -4
- package/src/components/ExternalLinks.tsx +6 -2
- package/src/components/Footer.tsx +35 -26
- package/src/components/LanguageProvider.tsx +0 -5
- package/src/components/LanguageSwitch.tsx +117 -6
- package/src/components/LocaleSwitch.tsx +33 -0
- package/src/components/Navbar.tsx +31 -8
- package/src/components/PostNavigation.tsx +55 -0
- package/src/components/PostSidebar.tsx +172 -126
- package/src/components/ReadingProgressBar.tsx +6 -21
- package/src/components/RelatedPosts.tsx +1 -1
- package/src/components/Search.tsx +420 -70
- package/src/components/SelectedBooksSection.tsx +12 -6
- package/src/components/ShareBar.tsx +115 -0
- package/src/components/SimpleLayoutHeader.tsx +5 -14
- package/src/components/SubscribePage.tsx +298 -0
- package/src/components/TagContentTabs.tsx +103 -0
- package/src/components/TagPageHeader.tsx +7 -13
- package/src/components/TagSidebar.tsx +142 -0
- package/src/components/TagsIndexClient.tsx +156 -0
- package/src/hooks/useScrollY.ts +41 -0
- package/src/i18n/translations.ts +110 -2
- package/src/layouts/PostLayout.tsx +34 -7
- package/src/layouts/SimpleLayout.tsx +53 -15
- package/src/lib/markdown.ts +71 -15
- package/src/lib/search-utils.test.ts +163 -0
- package/src/lib/search-utils.ts +39 -0
- package/src/types/pagefind.d.ts +42 -0
- package/src/components/TableOfContents.tsx +0 -158
package/src/lib/markdown.ts
CHANGED
|
@@ -68,6 +68,7 @@ export interface PostData {
|
|
|
68
68
|
readingTime: string;
|
|
69
69
|
content: string;
|
|
70
70
|
headings: Heading[];
|
|
71
|
+
contentLocales?: Record<string, { content: string; title?: string; excerpt?: string; headings?: Heading[] }>;
|
|
71
72
|
}
|
|
72
73
|
|
|
73
74
|
export function calculateReadingTime(content: string): string {
|
|
@@ -238,7 +239,7 @@ export function getAllPosts(): PostData[] {
|
|
|
238
239
|
|
|
239
240
|
if (match) {
|
|
240
241
|
dateFromFileName = match[1];
|
|
241
|
-
if (siteConfig.includeDateInUrl) {
|
|
242
|
+
if (siteConfig.posts?.includeDateInUrl) {
|
|
242
243
|
slug = rawName;
|
|
243
244
|
} else {
|
|
244
245
|
slug = match[2];
|
|
@@ -266,7 +267,7 @@ export function getAllPosts(): PostData[] {
|
|
|
266
267
|
let sDate = undefined;
|
|
267
268
|
if (sMatch) {
|
|
268
269
|
sDate = sMatch[1];
|
|
269
|
-
sSlug = siteConfig.includeDateInUrl ? sRawName : sMatch[2];
|
|
270
|
+
sSlug = siteConfig.posts?.includeDateInUrl ? sRawName : sMatch[2];
|
|
270
271
|
}
|
|
271
272
|
|
|
272
273
|
allPostsData.push(parseMarkdownFile(
|
|
@@ -294,7 +295,7 @@ export function getAllPosts(): PostData[] {
|
|
|
294
295
|
|
|
295
296
|
if (sMatch) {
|
|
296
297
|
sDate = sMatch[1];
|
|
297
|
-
sSlug = siteConfig.includeDateInUrl ? sItem.name : sMatch[2];
|
|
298
|
+
sSlug = siteConfig.posts?.includeDateInUrl ? sItem.name : sMatch[2];
|
|
298
299
|
}
|
|
299
300
|
|
|
300
301
|
allPostsData.push(parseMarkdownFile(
|
|
@@ -338,7 +339,7 @@ export function getAllPosts(): PostData[] {
|
|
|
338
339
|
return false;
|
|
339
340
|
}
|
|
340
341
|
|
|
341
|
-
if (!siteConfig.showFuturePosts) {
|
|
342
|
+
if (!siteConfig.posts?.showFuturePosts) {
|
|
342
343
|
const postDate = new Date(post.date);
|
|
343
344
|
const now = new Date();
|
|
344
345
|
if (postDate > now) return false;
|
|
@@ -396,7 +397,7 @@ function findPostFile(name: string, targetSlug: string): PostData | null {
|
|
|
396
397
|
export function getPostBySlug(slug: string): PostData | null {
|
|
397
398
|
let post: PostData | null = null;
|
|
398
399
|
|
|
399
|
-
if (siteConfig.includeDateInUrl) {
|
|
400
|
+
if (siteConfig.posts?.includeDateInUrl) {
|
|
400
401
|
post = findPostFile(slug, slug);
|
|
401
402
|
} else {
|
|
402
403
|
post = findPostFile(slug, slug);
|
|
@@ -445,7 +446,7 @@ export function getPostBySlug(slug: string): PostData | null {
|
|
|
445
446
|
return null;
|
|
446
447
|
}
|
|
447
448
|
|
|
448
|
-
if (!siteConfig.showFuturePosts) {
|
|
449
|
+
if (!siteConfig.posts?.showFuturePosts) {
|
|
449
450
|
const postDate = new Date(post.date);
|
|
450
451
|
const now = new Date();
|
|
451
452
|
if (postDate > now) return null;
|
|
@@ -453,18 +454,53 @@ export function getPostBySlug(slug: string): PostData | null {
|
|
|
453
454
|
return post;
|
|
454
455
|
}
|
|
455
456
|
|
|
457
|
+
/**
|
|
458
|
+
* Load the content and frontmatter of a locale variant file, e.g. about.zh.mdx.
|
|
459
|
+
* Returns null when the file does not exist or cannot be parsed.
|
|
460
|
+
*/
|
|
461
|
+
function loadLocaleContent(slug: string, locale: string): { content: string; title?: string; excerpt?: string; headings?: Heading[] } | null {
|
|
462
|
+
for (const ext of ['.mdx', '.md']) {
|
|
463
|
+
const filePath = path.join(pagesDirectory, `${slug}.${locale}${ext}`);
|
|
464
|
+
if (fs.existsSync(filePath)) {
|
|
465
|
+
try {
|
|
466
|
+
const { data, content } = matter(fs.readFileSync(filePath, 'utf8'));
|
|
467
|
+
const body = content.replace(/^\s*#\s+[^\n]+/, '').trim();
|
|
468
|
+
return {
|
|
469
|
+
content: body,
|
|
470
|
+
title: typeof data.title === 'string' ? data.title : undefined,
|
|
471
|
+
excerpt: typeof data.excerpt === 'string' ? data.excerpt : undefined,
|
|
472
|
+
headings: getHeadings(body),
|
|
473
|
+
};
|
|
474
|
+
} catch {
|
|
475
|
+
return null;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
return null;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* Collect contentLocales for all non-default locales that have a variant file.
|
|
484
|
+
*/
|
|
485
|
+
function attachContentLocales(page: PostData, slug: string): PostData {
|
|
486
|
+
const defaultLocale = siteConfig.i18n.defaultLocale;
|
|
487
|
+
const otherLocales = siteConfig.i18n.locales.filter(l => l !== defaultLocale);
|
|
488
|
+
const contentLocales: NonNullable<PostData['contentLocales']> = {};
|
|
489
|
+
for (const locale of otherLocales) {
|
|
490
|
+
const localeData = loadLocaleContent(slug, locale);
|
|
491
|
+
if (localeData !== null) contentLocales[locale] = localeData;
|
|
492
|
+
}
|
|
493
|
+
return Object.keys(contentLocales).length > 0 ? { ...page, contentLocales } : page;
|
|
494
|
+
}
|
|
495
|
+
|
|
456
496
|
export function getPageBySlug(slug: string): PostData | null {
|
|
457
497
|
try {
|
|
458
498
|
let fullPath = path.join(pagesDirectory, `${slug}.mdx`);
|
|
459
499
|
if (!fs.existsSync(fullPath)) {
|
|
460
500
|
fullPath = path.join(pagesDirectory, `${slug}.md`);
|
|
461
501
|
}
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
return null;
|
|
465
|
-
}
|
|
466
|
-
|
|
467
|
-
return parseMarkdownFile(fullPath, slug);
|
|
502
|
+
if (!fs.existsSync(fullPath)) return null;
|
|
503
|
+
return attachContentLocales(parseMarkdownFile(fullPath, slug), slug);
|
|
468
504
|
} catch {
|
|
469
505
|
return null;
|
|
470
506
|
}
|
|
@@ -473,11 +509,21 @@ export function getPageBySlug(slug: string): PostData | null {
|
|
|
473
509
|
export function getAllPages(): PostData[] {
|
|
474
510
|
const items = fs.readdirSync(pagesDirectory, { withFileTypes: true });
|
|
475
511
|
return items
|
|
476
|
-
.filter(item =>
|
|
512
|
+
.filter(item => {
|
|
513
|
+
if (!item.isFile()) return false;
|
|
514
|
+
if (!item.name.endsWith('.mdx') && !item.name.endsWith('.md')) return false;
|
|
515
|
+
// Exclude locale variant files (e.g. about.zh.mdx, about.en.mdx) — they are not standalone routes
|
|
516
|
+
const base = item.name.replace(/\.mdx?$/, '');
|
|
517
|
+
const parts = base.split('.');
|
|
518
|
+
if (parts.length > 1 && siteConfig.i18n.locales.includes(parts[parts.length - 1])) {
|
|
519
|
+
return false;
|
|
520
|
+
}
|
|
521
|
+
return true;
|
|
522
|
+
})
|
|
477
523
|
.map(item => {
|
|
478
524
|
const slug = item.name.replace(/\.mdx?$/, '');
|
|
479
525
|
const fullPath = path.join(pagesDirectory, item.name);
|
|
480
|
-
return parseMarkdownFile(fullPath, slug);
|
|
526
|
+
return attachContentLocales(parseMarkdownFile(fullPath, slug), slug);
|
|
481
527
|
});
|
|
482
528
|
}
|
|
483
529
|
|
|
@@ -658,6 +704,16 @@ export function getFeaturedPosts(): PostData[] {
|
|
|
658
704
|
return allPosts.filter(post => post.featured);
|
|
659
705
|
}
|
|
660
706
|
|
|
707
|
+
export function getAdjacentPosts(slug: string): { prev: PostData | null; next: PostData | null } {
|
|
708
|
+
const allPosts = getAllPosts(); // sorted desc by date (newest first)
|
|
709
|
+
const index = allPosts.findIndex(p => p.slug === slug);
|
|
710
|
+
if (index === -1) return { prev: null, next: null };
|
|
711
|
+
return {
|
|
712
|
+
prev: index < allPosts.length - 1 ? allPosts[index + 1] : null, // older post
|
|
713
|
+
next: index > 0 ? allPosts[index - 1] : null, // newer post
|
|
714
|
+
};
|
|
715
|
+
}
|
|
716
|
+
|
|
661
717
|
export function getFeaturedSeries(): Record<string, PostData[]> {
|
|
662
718
|
const allSeries = getAllSeries();
|
|
663
719
|
const featuredSeries: Record<string, PostData[]> = {};
|
|
@@ -1001,7 +1057,7 @@ export function getAllFlows(): FlowData[] {
|
|
|
1001
1057
|
return flows
|
|
1002
1058
|
.filter(flow => {
|
|
1003
1059
|
if (process.env.NODE_ENV === 'production' && flow.draft) return false;
|
|
1004
|
-
if (!siteConfig.showFuturePosts) {
|
|
1060
|
+
if (!siteConfig.posts?.showFuturePosts) {
|
|
1005
1061
|
const flowDate = new Date(flow.date);
|
|
1006
1062
|
const now = new Date();
|
|
1007
1063
|
if (flowDate > now) return false;
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { getResultType, getDateFromUrl, cleanTitle, stripMarkdown } from './search-utils';
|
|
3
|
+
|
|
4
|
+
// ─── getResultType ────────────────────────────────────────────────────────────
|
|
5
|
+
|
|
6
|
+
describe('getResultType', () => {
|
|
7
|
+
test('returns Flow for flow URLs', () => {
|
|
8
|
+
expect(getResultType('/flows/2026/01/15/')).toBe('Flow');
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test('returns Flow for any path containing /flows/', () => {
|
|
12
|
+
expect(getResultType('/flows/2024/12/31/')).toBe('Flow');
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test('returns Book for book chapter URLs', () => {
|
|
16
|
+
expect(getResultType('/books/my-book/chapter-1/')).toBe('Book');
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test('returns Book for book index URLs', () => {
|
|
20
|
+
expect(getResultType('/books/my-book/')).toBe('Book');
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('returns Post for post URLs', () => {
|
|
24
|
+
expect(getResultType('/posts/my-post/')).toBe('Post');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('returns Post for root static pages', () => {
|
|
28
|
+
expect(getResultType('/about/')).toBe('Post');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('returns Post for paginated post pages', () => {
|
|
32
|
+
expect(getResultType('/posts/page/2/')).toBe('Post');
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// ─── getDateFromUrl ───────────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
describe('getDateFromUrl', () => {
|
|
39
|
+
test('extracts YYYY-MM-DD from a flow URL', () => {
|
|
40
|
+
expect(getDateFromUrl('/flows/2026/01/15/')).toBe('2026-01-15');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('pads single-digit month and day correctly', () => {
|
|
44
|
+
expect(getDateFromUrl('/flows/2024/03/07/')).toBe('2024-03-07');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('returns empty string for post URLs', () => {
|
|
48
|
+
expect(getDateFromUrl('/posts/my-post/')).toBe('');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('returns empty string for book URLs', () => {
|
|
52
|
+
expect(getDateFromUrl('/books/my-book/chapter/')).toBe('');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('returns empty string when flow URL has no trailing slash', () => {
|
|
56
|
+
// Pagefind always returns URLs with trailing slash; guard against edge case
|
|
57
|
+
expect(getDateFromUrl('/flows/2024/12/31')).toBe('');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('returns empty string for the root path', () => {
|
|
61
|
+
expect(getDateFromUrl('/')).toBe('');
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// ─── cleanTitle ───────────────────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
describe('cleanTitle', () => {
|
|
68
|
+
test('strips " | Site Name" suffix', () => {
|
|
69
|
+
expect(cleanTitle('My Post | My Site')).toBe('My Post');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('returns the title unchanged when there is no suffix', () => {
|
|
73
|
+
expect(cleanTitle('My Post')).toBe('My Post');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('strips only the last " | " occurrence (keeps earlier pipes)', () => {
|
|
77
|
+
expect(cleanTitle('Part A | Part B | Site')).toBe('Part A | Part B');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('handles an empty string', () => {
|
|
81
|
+
expect(cleanTitle('')).toBe('');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('handles a title that is only a site suffix', () => {
|
|
85
|
+
expect(cleanTitle(' | Site')).toBe('');
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// ─── stripMarkdown ────────────────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
describe('stripMarkdown', () => {
|
|
92
|
+
test('strips fenced code blocks', () => {
|
|
93
|
+
const input = 'Hello\n```js\nconst x = 1;\n```\nWorld';
|
|
94
|
+
expect(stripMarkdown(input)).toBe('Hello World');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('strips inline code', () => {
|
|
98
|
+
expect(stripMarkdown('Use `npm install` to install')).toBe('Use to install');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('strips image syntax', () => {
|
|
102
|
+
expect(stripMarkdown(' after')).toBe('after');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('converts links to their visible text', () => {
|
|
106
|
+
expect(stripMarkdown('See [the docs](https://example.com) here')).toBe('See the docs here');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('strips HTML and JSX tags', () => {
|
|
110
|
+
expect(stripMarkdown('<div class="foo">content</div>')).toBe('content');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('strips ATX heading markers (# through ######)', () => {
|
|
114
|
+
expect(stripMarkdown('## Introduction')).toBe('Introduction');
|
|
115
|
+
expect(stripMarkdown('###### Deep heading')).toBe('Deep heading');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('strips bold and italic with asterisks', () => {
|
|
119
|
+
expect(stripMarkdown('**bold** and *italic* text')).toBe('bold and italic text');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('strips bold and italic with underscores', () => {
|
|
123
|
+
expect(stripMarkdown('__bold__ and _italic_ text')).toBe('bold and italic text');
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test('strips GFM strikethrough markers', () => {
|
|
127
|
+
expect(stripMarkdown('~~deleted~~ and ~~removed~~')).toBe('deleted and removed');
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('strips unordered list markers', () => {
|
|
131
|
+
expect(stripMarkdown('- item one\n- item two')).toBe('item one item two');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test('strips blockquote markers', () => {
|
|
135
|
+
expect(stripMarkdown('> quoted text')).toBe('quoted text');
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('strips ordered list markers', () => {
|
|
139
|
+
expect(stripMarkdown('1. First\n2. Second')).toBe('First Second');
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test('normalizes multiple spaces and newlines to a single space', () => {
|
|
143
|
+
expect(stripMarkdown('word1 word2\n\nword3')).toBe('word1 word2 word3');
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test('trims leading and trailing whitespace', () => {
|
|
147
|
+
expect(stripMarkdown(' hello world ')).toBe('hello world');
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test('caps output at 2000 characters', () => {
|
|
151
|
+
const long = 'a'.repeat(3000);
|
|
152
|
+
expect(stripMarkdown(long).length).toBe(2000);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test('returns empty string for empty input', () => {
|
|
156
|
+
expect(stripMarkdown('')).toBe('');
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test('handles mixed markdown in one passage', () => {
|
|
160
|
+
const input = '## Title\n\n**Bold** and [link](http://example.com).\n\n- item\n\n> quote';
|
|
161
|
+
expect(stripMarkdown(input)).toBe('Title Bold and link. item quote');
|
|
162
|
+
});
|
|
163
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export type ContentType = 'All' | 'Post' | 'Flow' | 'Book';
|
|
2
|
+
|
|
3
|
+
/** Derive content type from a Pagefind result URL. */
|
|
4
|
+
export function getResultType(url: string): Exclude<ContentType, 'All'> {
|
|
5
|
+
if (url.includes('/flows/')) return 'Flow';
|
|
6
|
+
if (url.includes('/books/')) return 'Book';
|
|
7
|
+
return 'Post';
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Extract YYYY-MM-DD from a flow URL like /flows/2026/01/15/ */
|
|
11
|
+
export function getDateFromUrl(url: string): string {
|
|
12
|
+
const m = url.match(/\/flows\/(\d{4})\/(\d{2})\/(\d{2})\//);
|
|
13
|
+
return m ? `${m[1]}-${m[2]}-${m[3]}` : '';
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Strip the " | Site Name" suffix that Pagefind picks up from <title>. */
|
|
17
|
+
export function cleanTitle(raw: string): string {
|
|
18
|
+
const i = raw.lastIndexOf(' | ');
|
|
19
|
+
return i >= 0 ? raw.slice(0, i) : raw;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Strip markdown/MDX syntax to plain text for full-content indexing. */
|
|
23
|
+
export function stripMarkdown(text: string): string {
|
|
24
|
+
return text
|
|
25
|
+
.replace(/```[\s\S]*?```/g, ' ') // fenced code blocks
|
|
26
|
+
.replace(/`[^`\n]+`/g, ' ') // inline code
|
|
27
|
+
.replace(/!\[.*?\]\(.*?\)/g, '') // images
|
|
28
|
+
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') // links → text
|
|
29
|
+
.replace(/<[^>]+>/g, ' ') // HTML/JSX/MDX tags
|
|
30
|
+
.replace(/^#{1,6}\s+/gm, '') // heading markers
|
|
31
|
+
.replace(/\*{1,2}([^*\n]+)\*{1,2}/g, '$1') // bold/italic (*)
|
|
32
|
+
.replace(/_{1,2}([^_\n]+)_{1,2}/g, '$1') // bold/italic (_)
|
|
33
|
+
.replace(/~~([^~\n]+)~~/g, '$1') // strikethrough
|
|
34
|
+
.replace(/^\s*[-*+>]\s+/gm, '') // lists + blockquotes
|
|
35
|
+
.replace(/^\s*\d+\.\s+/gm, '') // ordered lists
|
|
36
|
+
.replace(/\s+/g, ' ') // normalize whitespace
|
|
37
|
+
.trim()
|
|
38
|
+
.slice(0, 2000); // cap for index size
|
|
39
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type declarations for Pagefind's JS API.
|
|
3
|
+
* Pagefind is generated at build time (`pagefind --site out`) and
|
|
4
|
+
* loaded at runtime via a dynamic import. The module does not exist
|
|
5
|
+
* at compile time, so we declare it manually here.
|
|
6
|
+
*
|
|
7
|
+
* Docs: https://pagefind.app/docs/api/
|
|
8
|
+
*/
|
|
9
|
+
declare module '/pagefind/pagefind.js' {
|
|
10
|
+
export interface PagefindSearchFragment {
|
|
11
|
+
/** URL of the matching page, e.g. "/posts/my-post/" */
|
|
12
|
+
url: string;
|
|
13
|
+
/** Excerpt with matched terms wrapped in <mark> tags */
|
|
14
|
+
excerpt: string;
|
|
15
|
+
/** Metadata extracted from the page */
|
|
16
|
+
meta: {
|
|
17
|
+
title?: string;
|
|
18
|
+
image?: string;
|
|
19
|
+
/** Any custom data-pagefind-meta keys defined in the site */
|
|
20
|
+
[key: string]: string | undefined;
|
|
21
|
+
};
|
|
22
|
+
word_count: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface PagefindSearchResult {
|
|
26
|
+
/** Unique result ID, e.g. "en_6fceec9" */
|
|
27
|
+
id: string;
|
|
28
|
+
/** Load the full result data (lazy, returns only the matching page chunk) */
|
|
29
|
+
data: () => Promise<PagefindSearchFragment>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface PagefindSearchResponse {
|
|
33
|
+
results: PagefindSearchResult[];
|
|
34
|
+
unfilteredResultCount: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Initialise Pagefind — must be called before search() */
|
|
38
|
+
export function init(): Promise<void>;
|
|
39
|
+
|
|
40
|
+
/** Run a search and return lazy result handles */
|
|
41
|
+
export function search(query: string): Promise<PagefindSearchResponse>;
|
|
42
|
+
}
|
|
@@ -1,158 +0,0 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
|
|
3
|
-
import { useState, useEffect, useCallback } from 'react';
|
|
4
|
-
import { Heading } from '@/lib/markdown';
|
|
5
|
-
import { useLanguage } from '@/components/LanguageProvider';
|
|
6
|
-
|
|
7
|
-
export default function TableOfContents({ headings }: { headings: Heading[] }) {
|
|
8
|
-
const { t } = useLanguage();
|
|
9
|
-
const [activeId, setActiveId] = useState<string>('');
|
|
10
|
-
const [readProgress, setReadProgress] = useState(0);
|
|
11
|
-
|
|
12
|
-
// Track scroll position and active heading
|
|
13
|
-
const handleScroll = useCallback(() => {
|
|
14
|
-
// Calculate read progress
|
|
15
|
-
const scrollTop = window.scrollY;
|
|
16
|
-
const docHeight = document.documentElement.scrollHeight - window.innerHeight;
|
|
17
|
-
const progress = docHeight > 0 ? (scrollTop / docHeight) * 100 : 0;
|
|
18
|
-
setReadProgress(Math.min(100, Math.max(0, progress)));
|
|
19
|
-
|
|
20
|
-
// Find active heading
|
|
21
|
-
const headingElements = headings
|
|
22
|
-
.map(h => document.getElementById(h.id))
|
|
23
|
-
.filter(Boolean) as HTMLElement[];
|
|
24
|
-
|
|
25
|
-
if (headingElements.length === 0) return;
|
|
26
|
-
|
|
27
|
-
// Find the heading that's currently in view
|
|
28
|
-
const scrollPosition = scrollTop + 100; // Offset for navbar
|
|
29
|
-
|
|
30
|
-
let currentHeading = headingElements[0];
|
|
31
|
-
for (const heading of headingElements) {
|
|
32
|
-
if (heading.offsetTop <= scrollPosition) {
|
|
33
|
-
currentHeading = heading;
|
|
34
|
-
} else {
|
|
35
|
-
break;
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
if (currentHeading) {
|
|
40
|
-
setActiveId(currentHeading.id);
|
|
41
|
-
}
|
|
42
|
-
}, [headings]);
|
|
43
|
-
|
|
44
|
-
useEffect(() => {
|
|
45
|
-
// Initial check on mount via animation frame to avoid cascading render error
|
|
46
|
-
const rafId = requestAnimationFrame(handleScroll);
|
|
47
|
-
|
|
48
|
-
window.addEventListener('scroll', handleScroll, { passive: true });
|
|
49
|
-
return () => {
|
|
50
|
-
cancelAnimationFrame(rafId);
|
|
51
|
-
window.removeEventListener('scroll', handleScroll);
|
|
52
|
-
};
|
|
53
|
-
}, [handleScroll]);
|
|
54
|
-
|
|
55
|
-
// Smooth scroll to heading
|
|
56
|
-
const scrollToHeading = (e: React.MouseEvent<HTMLAnchorElement>, id: string) => {
|
|
57
|
-
e.preventDefault();
|
|
58
|
-
const element = document.getElementById(id);
|
|
59
|
-
if (element) {
|
|
60
|
-
const offset = 80; // Navbar height
|
|
61
|
-
const elementPosition = element.getBoundingClientRect().top + window.scrollY;
|
|
62
|
-
window.scrollTo({
|
|
63
|
-
top: elementPosition - offset,
|
|
64
|
-
behavior: 'smooth'
|
|
65
|
-
});
|
|
66
|
-
// Update URL without scrolling
|
|
67
|
-
history.pushState(null, '', `#${id}`);
|
|
68
|
-
}
|
|
69
|
-
};
|
|
70
|
-
|
|
71
|
-
if (headings.length === 0) return null;
|
|
72
|
-
|
|
73
|
-
// Find active index for progress calculation
|
|
74
|
-
const activeIndex = headings.findIndex(h => h.id === activeId);
|
|
75
|
-
|
|
76
|
-
return (
|
|
77
|
-
<nav
|
|
78
|
-
className="hidden lg:block sticky top-28 self-start w-56 pl-6 max-h-[calc(100vh-8rem)] overflow-y-auto scrollbar-hide"
|
|
79
|
-
aria-label="Table of contents"
|
|
80
|
-
>
|
|
81
|
-
{/* Header with progress */}
|
|
82
|
-
<div className="flex items-center justify-between mb-4 pb-3 border-b border-muted/10">
|
|
83
|
-
<h2 className="text-[10px] font-sans font-bold uppercase tracking-widest text-muted">
|
|
84
|
-
{t('on_this_page')}
|
|
85
|
-
</h2>
|
|
86
|
-
<span className="text-[10px] font-mono text-muted/60">
|
|
87
|
-
{Math.round(readProgress)}%
|
|
88
|
-
</span>
|
|
89
|
-
</div>
|
|
90
|
-
|
|
91
|
-
{/* Progress bar */}
|
|
92
|
-
<div className="h-0.5 bg-muted/10 rounded-full overflow-hidden mb-5">
|
|
93
|
-
<div
|
|
94
|
-
className="h-full bg-accent/50 rounded-full transition-all duration-150"
|
|
95
|
-
style={{ width: `${readProgress}%` }}
|
|
96
|
-
/>
|
|
97
|
-
</div>
|
|
98
|
-
|
|
99
|
-
{/* Headings list */}
|
|
100
|
-
<ul className="space-y-1 relative">
|
|
101
|
-
{/* Active indicator line */}
|
|
102
|
-
<div className="absolute left-0 top-0 bottom-0 w-px bg-muted/10" />
|
|
103
|
-
|
|
104
|
-
{headings.map((heading, index) => {
|
|
105
|
-
const isActive = heading.id === activeId;
|
|
106
|
-
const isPast = activeIndex > -1 && index < activeIndex;
|
|
107
|
-
const isH3 = heading.level === 3;
|
|
108
|
-
|
|
109
|
-
return (
|
|
110
|
-
<li
|
|
111
|
-
key={heading.id}
|
|
112
|
-
className={`relative ${isH3 ? 'pl-4' : ''}`}
|
|
113
|
-
>
|
|
114
|
-
{/* Active indicator */}
|
|
115
|
-
{isActive && (
|
|
116
|
-
<div
|
|
117
|
-
className="absolute left-0 w-0.5 bg-accent rounded-full transition-all duration-200"
|
|
118
|
-
style={{
|
|
119
|
-
top: '4px',
|
|
120
|
-
height: 'calc(100% - 8px)'
|
|
121
|
-
}}
|
|
122
|
-
/>
|
|
123
|
-
)}
|
|
124
|
-
|
|
125
|
-
<a
|
|
126
|
-
href={`#${heading.id}`}
|
|
127
|
-
onClick={(e) => scrollToHeading(e, heading.id)}
|
|
128
|
-
className={`block py-1.5 pl-4 text-sm leading-snug transition-all duration-200 ${
|
|
129
|
-
isActive
|
|
130
|
-
? 'text-accent font-medium'
|
|
131
|
-
: isPast
|
|
132
|
-
? 'text-foreground/60 hover:text-foreground'
|
|
133
|
-
: 'text-muted/70 hover:text-foreground'
|
|
134
|
-
}`}
|
|
135
|
-
aria-current={isActive ? 'location' : undefined}
|
|
136
|
-
>
|
|
137
|
-
{heading.text}
|
|
138
|
-
</a>
|
|
139
|
-
</li>
|
|
140
|
-
);
|
|
141
|
-
})}
|
|
142
|
-
</ul>
|
|
143
|
-
|
|
144
|
-
{/* Back to top */}
|
|
145
|
-
{readProgress > 20 && (
|
|
146
|
-
<button
|
|
147
|
-
onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })}
|
|
148
|
-
className="mt-6 pt-4 border-t border-muted/10 w-full text-left text-xs text-muted hover:text-accent transition-colors flex items-center gap-1.5"
|
|
149
|
-
>
|
|
150
|
-
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
151
|
-
<path strokeLinecap="round" strokeLinejoin="round" d="M5 10l7-7m0 0l7 7m-7-7v18" />
|
|
152
|
-
</svg>
|
|
153
|
-
{t('back_to_top')}
|
|
154
|
-
</button>
|
|
155
|
-
)}
|
|
156
|
-
</nav>
|
|
157
|
-
);
|
|
158
|
-
}
|