@hutusi/amytis 1.6.0 → 1.8.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 +49 -0
- package/GEMINI.md +12 -2
- package/README.md +14 -0
- package/TODO.md +24 -16
- package/bun.lock +8 -3
- package/content/about.mdx +1 -0
- package/content/about.zh.mdx +21 -0
- package/content/flows/2026/02/05.md +0 -1
- package/content/flows/2026/02/10.mdx +2 -1
- package/content/flows/2026/02/15.md +2 -1
- package/content/flows/2026/02/18.mdx +2 -1
- package/content/flows/2026/02/20.md +15 -0
- package/content/links.mdx +42 -0
- package/content/links.zh.mdx +41 -0
- package/content/notes/algorithms-and-data-structures.mdx +51 -0
- package/content/notes/digital-garden-philosophy.mdx +36 -0
- package/content/notes/react-server-components.mdx +49 -0
- package/content/notes/tailwind-v4.mdx +45 -0
- package/content/notes/zettelkasten-method.mdx +33 -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 +16 -0
- package/docs/CONTRIBUTING.md +11 -0
- package/docs/DIGITAL_GARDEN.md +64 -0
- package/package.json +8 -3
- package/scripts/copy-assets.ts +1 -1
- package/scripts/generate-knowledge-graph.ts +162 -0
- package/scripts/new-flow.ts +0 -5
- package/scripts/new-note.ts +53 -0
- package/site.config.ts +146 -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 +51 -31
- package/src/app/flows/[year]/[month]/page.tsx +15 -13
- package/src/app/flows/[year]/page.tsx +22 -15
- package/src/app/flows/page/[page]/page.tsx +3 -9
- package/src/app/flows/page.tsx +3 -8
- package/src/app/globals.css +41 -0
- package/src/app/graph/page.tsx +19 -0
- package/src/app/layout.tsx +47 -21
- package/src/app/notes/[slug]/page.tsx +128 -0
- package/src/app/notes/page/[page]/page.tsx +58 -0
- package/src/app/notes/page.tsx +31 -0
- package/src/app/page.tsx +134 -72
- package/src/app/posts/[slug]/page.tsx +8 -12
- package/src/app/search.json/route.ts +15 -1
- 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/Backlinks.tsx +39 -0
- package/src/components/Comments.tsx +20 -4
- package/src/components/ExternalLinks.tsx +6 -2
- package/src/components/FlowCalendarSidebar.tsx +4 -2
- package/src/components/FlowContent.tsx +4 -3
- package/src/components/FlowHubTabs.tsx +50 -0
- package/src/components/FlowTimelineEntry.tsx +7 -9
- package/src/components/Footer.tsx +35 -26
- package/src/components/KnowledgeGraph.tsx +324 -0
- package/src/components/LanguageProvider.tsx +0 -5
- package/src/components/LanguageSwitch.tsx +117 -6
- package/src/components/LocaleSwitch.tsx +33 -0
- package/src/components/MarkdownRenderer.tsx +13 -2
- package/src/components/Navbar.tsx +266 -17
- package/src/components/NoteContent.tsx +123 -0
- package/src/components/NoteSidebar.tsx +132 -0
- package/src/components/PostNavigation.tsx +55 -0
- package/src/components/PostSidebar.tsx +172 -126
- package/src/components/ReadingProgressBar.tsx +6 -21
- package/src/components/RecentNotesSection.tsx +6 -11
- package/src/components/RelatedPosts.tsx +1 -1
- package/src/components/Search.tsx +29 -5
- 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 +102 -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 +105 -1
- package/src/layouts/PostLayout.tsx +40 -8
- package/src/layouts/SimpleLayout.tsx +53 -15
- package/src/lib/markdown.ts +347 -18
- package/src/lib/remark-wikilinks.ts +59 -0
- package/src/lib/search-utils.ts +2 -1
- package/src/components/TableOfContents.tsx +0 -158
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
import Link from 'next/link';
|
|
2
|
-
import { getAuthorSlug, PostData } from '@/lib/markdown';
|
|
2
|
+
import { getAuthorSlug, PostData, BacklinkSource, SlugRegistryEntry } from '@/lib/markdown';
|
|
3
3
|
import MarkdownRenderer from '@/components/MarkdownRenderer';
|
|
4
4
|
import RelatedPosts from '@/components/RelatedPosts';
|
|
5
5
|
import SeriesList from '@/components/SeriesList';
|
|
6
6
|
import PostSidebar from '@/components/PostSidebar';
|
|
7
7
|
import Comments from '@/components/Comments';
|
|
8
8
|
import ExternalLinks from '@/components/ExternalLinks';
|
|
9
|
+
import Backlinks from '@/components/Backlinks';
|
|
9
10
|
import Tag from '@/components/Tag';
|
|
10
11
|
import ReadingProgressBar from '@/components/ReadingProgressBar';
|
|
12
|
+
import PostNavigation from '@/components/PostNavigation';
|
|
13
|
+
import AuthorCard from '@/components/AuthorCard';
|
|
14
|
+
import ShareBar from '@/components/ShareBar';
|
|
11
15
|
import { siteConfig } from '../../site.config';
|
|
12
16
|
import { t } from '@/lib/i18n';
|
|
13
17
|
|
|
@@ -16,15 +20,20 @@ interface PostLayoutProps {
|
|
|
16
20
|
relatedPosts?: PostData[];
|
|
17
21
|
seriesPosts?: PostData[];
|
|
18
22
|
seriesTitle?: string;
|
|
23
|
+
prevPost?: PostData | null;
|
|
24
|
+
nextPost?: PostData | null;
|
|
25
|
+
backlinks?: BacklinkSource[];
|
|
26
|
+
slugRegistry?: Map<string, SlugRegistryEntry>;
|
|
19
27
|
}
|
|
20
28
|
|
|
21
|
-
export default function PostLayout({ post, relatedPosts, seriesPosts, seriesTitle }: PostLayoutProps) {
|
|
22
|
-
const showToc = siteConfig.toc !== false && post.toc !== false && post.headings && post.headings.length > 0;
|
|
29
|
+
export default function PostLayout({ post, relatedPosts, seriesPosts, seriesTitle, prevPost, nextPost, backlinks, slugRegistry }: PostLayoutProps) {
|
|
30
|
+
const showToc = siteConfig.posts?.toc !== false && post.toc !== false && post.headings && post.headings.length > 0;
|
|
23
31
|
const hasSeries = !!(post.series && seriesPosts && seriesPosts.length > 0);
|
|
24
32
|
const showSidebar = showToc || hasSeries;
|
|
33
|
+
const postUrl = `${siteConfig.baseUrl}/posts/${post.slug}`;
|
|
25
34
|
|
|
26
35
|
return (
|
|
27
|
-
<div className=
|
|
36
|
+
<div className="layout-container">
|
|
28
37
|
<ReadingProgressBar />
|
|
29
38
|
<div className={showSidebar
|
|
30
39
|
? 'grid grid-cols-1 lg:grid-cols-[280px_minmax(0,1fr)] gap-8 items-start'
|
|
@@ -38,11 +47,13 @@ export default function PostLayout({ post, relatedPosts, seriesPosts, seriesTitl
|
|
|
38
47
|
posts={hasSeries ? seriesPosts : undefined}
|
|
39
48
|
currentSlug={post.slug}
|
|
40
49
|
headings={showToc ? post.headings : []}
|
|
50
|
+
shareUrl={postUrl}
|
|
51
|
+
shareTitle={post.title}
|
|
41
52
|
/>
|
|
42
53
|
)}
|
|
43
54
|
|
|
44
|
-
<article className="min-w-0 max-w-3xl">
|
|
45
|
-
<header className="mb-16 border-b border-muted/10 pb-
|
|
55
|
+
<article className="min-w-0 max-w-3xl mx-auto">
|
|
56
|
+
<header className="mb-16 border-b border-muted/10 pb-8">
|
|
46
57
|
{post.draft && (
|
|
47
58
|
<div className="mb-4">
|
|
48
59
|
<span className="text-xs font-bold text-red-500 bg-red-100 dark:bg-red-900/30 px-2 py-1 rounded tracking-widest inline-block">
|
|
@@ -102,15 +113,36 @@ export default function PostLayout({ post, relatedPosts, seriesPosts, seriesTitl
|
|
|
102
113
|
</div>
|
|
103
114
|
)}
|
|
104
115
|
|
|
105
|
-
<MarkdownRenderer content={post.content} latex={post.latex} slug={post.slug} />
|
|
116
|
+
<MarkdownRenderer content={post.content} latex={post.latex} slug={post.slug} slugRegistry={slugRegistry} />
|
|
117
|
+
|
|
118
|
+
{post.tags && post.tags.length > 0 && (
|
|
119
|
+
<div className="mt-12 pt-12 border-t border-muted/20 flex flex-wrap items-center gap-2">
|
|
120
|
+
<span className="text-[10px] font-sans font-bold uppercase tracking-widest text-muted mr-1">{t('tags')}</span>
|
|
121
|
+
{post.tags.map((tag) => (
|
|
122
|
+
<Tag key={tag} tag={tag} variant="default" />
|
|
123
|
+
))}
|
|
124
|
+
</div>
|
|
125
|
+
)}
|
|
106
126
|
|
|
107
127
|
{post.externalLinks && post.externalLinks.length > 0 && (
|
|
108
128
|
<ExternalLinks links={post.externalLinks} />
|
|
109
129
|
)}
|
|
110
130
|
|
|
111
|
-
<
|
|
131
|
+
<Backlinks backlinks={backlinks ?? []} />
|
|
132
|
+
|
|
133
|
+
<ShareBar
|
|
134
|
+
url={postUrl}
|
|
135
|
+
title={post.title}
|
|
136
|
+
className={showSidebar ? 'mt-8 lg:hidden' : 'mt-8'}
|
|
137
|
+
/>
|
|
138
|
+
|
|
139
|
+
<AuthorCard authors={post.authors} />
|
|
140
|
+
|
|
141
|
+
<PostNavigation prev={prevPost ?? null} next={nextPost ?? null} />
|
|
112
142
|
|
|
113
143
|
<Comments slug={post.slug} />
|
|
144
|
+
|
|
145
|
+
<RelatedPosts posts={relatedPosts || []} />
|
|
114
146
|
</article>
|
|
115
147
|
</div>
|
|
116
148
|
</div>
|
|
@@ -1,31 +1,69 @@
|
|
|
1
1
|
import { PostData } from '@/lib/markdown';
|
|
2
2
|
import MarkdownRenderer from '@/components/MarkdownRenderer';
|
|
3
3
|
import SimpleLayoutHeader from '@/components/SimpleLayoutHeader';
|
|
4
|
+
import LocaleSwitch from '@/components/LocaleSwitch';
|
|
5
|
+
import PostSidebar from '@/components/PostSidebar';
|
|
4
6
|
import { TranslationKey } from '@/i18n/translations';
|
|
7
|
+
import { siteConfig } from '../../site.config';
|
|
5
8
|
|
|
6
9
|
interface SimpleLayoutProps {
|
|
7
10
|
post: PostData;
|
|
8
11
|
titleKey?: TranslationKey;
|
|
9
12
|
subtitleKey?: TranslationKey;
|
|
10
|
-
titleOverride?: string | Record<string, string>;
|
|
11
|
-
subtitleOverride?: string | Record<string, string>;
|
|
12
13
|
}
|
|
13
14
|
|
|
14
|
-
export default function SimpleLayout({ post, titleKey, subtitleKey
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
/>
|
|
15
|
+
export default function SimpleLayout({ post, titleKey, subtitleKey }: SimpleLayoutProps) {
|
|
16
|
+
const defaultLocale = siteConfig.i18n.defaultLocale;
|
|
17
|
+
const localeEntries = Object.entries(post.contentLocales ?? {});
|
|
18
|
+
const showToc = siteConfig.posts?.toc !== false && post.toc !== false && post.headings?.length > 0;
|
|
19
|
+
const localeHeadings = post.contentLocales
|
|
20
|
+
? Object.fromEntries(
|
|
21
|
+
Object.entries(post.contentLocales)
|
|
22
|
+
.filter(([, data]) => data.headings && data.headings.length > 0)
|
|
23
|
+
.map(([locale, data]) => [locale, data.headings!])
|
|
24
|
+
)
|
|
25
|
+
: undefined;
|
|
26
26
|
|
|
27
|
+
const articleContent = (
|
|
28
|
+
<>
|
|
29
|
+
<SimpleLayoutHeader
|
|
30
|
+
title={post.title}
|
|
31
|
+
excerpt={post.excerpt}
|
|
32
|
+
titleKey={titleKey}
|
|
33
|
+
subtitleKey={subtitleKey}
|
|
34
|
+
contentLocales={post.contentLocales}
|
|
35
|
+
/>
|
|
36
|
+
{localeEntries.length > 0 ? (
|
|
37
|
+
<LocaleSwitch>
|
|
38
|
+
<div data-locale={defaultLocale}>
|
|
39
|
+
<MarkdownRenderer content={post.content} latex={post.latex} slug={post.slug} />
|
|
40
|
+
</div>
|
|
41
|
+
{localeEntries.map(([locale, data]) => (
|
|
42
|
+
<div key={locale} data-locale={locale} style={{ display: 'none' }}>
|
|
43
|
+
<MarkdownRenderer content={data.content} latex={post.latex} slug={post.slug} />
|
|
44
|
+
</div>
|
|
45
|
+
))}
|
|
46
|
+
</LocaleSwitch>
|
|
47
|
+
) : (
|
|
27
48
|
<MarkdownRenderer content={post.content} latex={post.latex} slug={post.slug} />
|
|
28
|
-
|
|
49
|
+
)}
|
|
50
|
+
</>
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<div className="layout-main">
|
|
55
|
+
{showToc ? (
|
|
56
|
+
<div className="grid grid-cols-1 lg:grid-cols-[280px_minmax(0,1fr)] gap-8 items-start">
|
|
57
|
+
<PostSidebar currentSlug={post.slug} headings={post.headings} localeHeadings={localeHeadings} />
|
|
58
|
+
<article className="min-w-0 max-w-3xl">
|
|
59
|
+
{articleContent}
|
|
60
|
+
</article>
|
|
61
|
+
</div>
|
|
62
|
+
) : (
|
|
63
|
+
<article className="max-w-3xl mx-auto">
|
|
64
|
+
{articleContent}
|
|
65
|
+
</article>
|
|
66
|
+
)}
|
|
29
67
|
</div>
|
|
30
68
|
);
|
|
31
69
|
}
|
package/src/lib/markdown.ts
CHANGED
|
@@ -10,6 +10,7 @@ const pagesDirectory = path.join(process.cwd(), 'content');
|
|
|
10
10
|
const seriesDirectory = path.join(process.cwd(), 'content', 'series');
|
|
11
11
|
const booksDirectory = path.join(process.cwd(), 'content', 'books');
|
|
12
12
|
const flowsDirectory = path.join(process.cwd(), 'content', 'flows');
|
|
13
|
+
const notesDirectory = path.join(process.cwd(), 'content', 'notes');
|
|
13
14
|
|
|
14
15
|
const ExternalLinkSchema = z.object({
|
|
15
16
|
name: z.string(),
|
|
@@ -68,6 +69,7 @@ export interface PostData {
|
|
|
68
69
|
readingTime: string;
|
|
69
70
|
content: string;
|
|
70
71
|
headings: Heading[];
|
|
72
|
+
contentLocales?: Record<string, { content: string; title?: string; excerpt?: string; headings?: Heading[] }>;
|
|
71
73
|
}
|
|
72
74
|
|
|
73
75
|
export function calculateReadingTime(content: string): string {
|
|
@@ -97,7 +99,7 @@ export function generateExcerpt(content: string): string {
|
|
|
97
99
|
plain = plain.replace(/!\[[^\]]*\]\([^)]+\)/g, '');
|
|
98
100
|
plain = plain.replace(/\*\[([^\]]+)\*\]\([^)]+\)/g, '$1');
|
|
99
101
|
plain = plain.replace(/(\$\*\*|__|\*|_)/g, '');
|
|
100
|
-
plain = plain.replace(/`[^`]
|
|
102
|
+
plain = plain.replace(/`([^`]+)`/g, '$1');
|
|
101
103
|
plain = plain.replace(/^>\s+/gm, '');
|
|
102
104
|
plain = plain.replace(/\s+/g, ' ').trim();
|
|
103
105
|
|
|
@@ -238,7 +240,7 @@ export function getAllPosts(): PostData[] {
|
|
|
238
240
|
|
|
239
241
|
if (match) {
|
|
240
242
|
dateFromFileName = match[1];
|
|
241
|
-
if (siteConfig.includeDateInUrl) {
|
|
243
|
+
if (siteConfig.posts?.includeDateInUrl) {
|
|
242
244
|
slug = rawName;
|
|
243
245
|
} else {
|
|
244
246
|
slug = match[2];
|
|
@@ -266,7 +268,7 @@ export function getAllPosts(): PostData[] {
|
|
|
266
268
|
let sDate = undefined;
|
|
267
269
|
if (sMatch) {
|
|
268
270
|
sDate = sMatch[1];
|
|
269
|
-
sSlug = siteConfig.includeDateInUrl ? sRawName : sMatch[2];
|
|
271
|
+
sSlug = siteConfig.posts?.includeDateInUrl ? sRawName : sMatch[2];
|
|
270
272
|
}
|
|
271
273
|
|
|
272
274
|
allPostsData.push(parseMarkdownFile(
|
|
@@ -294,7 +296,7 @@ export function getAllPosts(): PostData[] {
|
|
|
294
296
|
|
|
295
297
|
if (sMatch) {
|
|
296
298
|
sDate = sMatch[1];
|
|
297
|
-
sSlug = siteConfig.includeDateInUrl ? sItem.name : sMatch[2];
|
|
299
|
+
sSlug = siteConfig.posts?.includeDateInUrl ? sItem.name : sMatch[2];
|
|
298
300
|
}
|
|
299
301
|
|
|
300
302
|
allPostsData.push(parseMarkdownFile(
|
|
@@ -338,7 +340,7 @@ export function getAllPosts(): PostData[] {
|
|
|
338
340
|
return false;
|
|
339
341
|
}
|
|
340
342
|
|
|
341
|
-
if (!siteConfig.showFuturePosts) {
|
|
343
|
+
if (!siteConfig.posts?.showFuturePosts) {
|
|
342
344
|
const postDate = new Date(post.date);
|
|
343
345
|
const now = new Date();
|
|
344
346
|
if (postDate > now) return false;
|
|
@@ -396,7 +398,7 @@ function findPostFile(name: string, targetSlug: string): PostData | null {
|
|
|
396
398
|
export function getPostBySlug(slug: string): PostData | null {
|
|
397
399
|
let post: PostData | null = null;
|
|
398
400
|
|
|
399
|
-
if (siteConfig.includeDateInUrl) {
|
|
401
|
+
if (siteConfig.posts?.includeDateInUrl) {
|
|
400
402
|
post = findPostFile(slug, slug);
|
|
401
403
|
} else {
|
|
402
404
|
post = findPostFile(slug, slug);
|
|
@@ -445,7 +447,7 @@ export function getPostBySlug(slug: string): PostData | null {
|
|
|
445
447
|
return null;
|
|
446
448
|
}
|
|
447
449
|
|
|
448
|
-
if (!siteConfig.showFuturePosts) {
|
|
450
|
+
if (!siteConfig.posts?.showFuturePosts) {
|
|
449
451
|
const postDate = new Date(post.date);
|
|
450
452
|
const now = new Date();
|
|
451
453
|
if (postDate > now) return null;
|
|
@@ -453,18 +455,53 @@ export function getPostBySlug(slug: string): PostData | null {
|
|
|
453
455
|
return post;
|
|
454
456
|
}
|
|
455
457
|
|
|
458
|
+
/**
|
|
459
|
+
* Load the content and frontmatter of a locale variant file, e.g. about.zh.mdx.
|
|
460
|
+
* Returns null when the file does not exist or cannot be parsed.
|
|
461
|
+
*/
|
|
462
|
+
function loadLocaleContent(slug: string, locale: string): { content: string; title?: string; excerpt?: string; headings?: Heading[] } | null {
|
|
463
|
+
for (const ext of ['.mdx', '.md']) {
|
|
464
|
+
const filePath = path.join(pagesDirectory, `${slug}.${locale}${ext}`);
|
|
465
|
+
if (fs.existsSync(filePath)) {
|
|
466
|
+
try {
|
|
467
|
+
const { data, content } = matter(fs.readFileSync(filePath, 'utf8'));
|
|
468
|
+
const body = content.replace(/^\s*#\s+[^\n]+/, '').trim();
|
|
469
|
+
return {
|
|
470
|
+
content: body,
|
|
471
|
+
title: typeof data.title === 'string' ? data.title : undefined,
|
|
472
|
+
excerpt: typeof data.excerpt === 'string' ? data.excerpt : undefined,
|
|
473
|
+
headings: getHeadings(body),
|
|
474
|
+
};
|
|
475
|
+
} catch {
|
|
476
|
+
return null;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
return null;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* Collect contentLocales for all non-default locales that have a variant file.
|
|
485
|
+
*/
|
|
486
|
+
function attachContentLocales(page: PostData, slug: string): PostData {
|
|
487
|
+
const defaultLocale = siteConfig.i18n.defaultLocale;
|
|
488
|
+
const otherLocales = siteConfig.i18n.locales.filter(l => l !== defaultLocale);
|
|
489
|
+
const contentLocales: NonNullable<PostData['contentLocales']> = {};
|
|
490
|
+
for (const locale of otherLocales) {
|
|
491
|
+
const localeData = loadLocaleContent(slug, locale);
|
|
492
|
+
if (localeData !== null) contentLocales[locale] = localeData;
|
|
493
|
+
}
|
|
494
|
+
return Object.keys(contentLocales).length > 0 ? { ...page, contentLocales } : page;
|
|
495
|
+
}
|
|
496
|
+
|
|
456
497
|
export function getPageBySlug(slug: string): PostData | null {
|
|
457
498
|
try {
|
|
458
499
|
let fullPath = path.join(pagesDirectory, `${slug}.mdx`);
|
|
459
500
|
if (!fs.existsSync(fullPath)) {
|
|
460
501
|
fullPath = path.join(pagesDirectory, `${slug}.md`);
|
|
461
502
|
}
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
return null;
|
|
465
|
-
}
|
|
466
|
-
|
|
467
|
-
return parseMarkdownFile(fullPath, slug);
|
|
503
|
+
if (!fs.existsSync(fullPath)) return null;
|
|
504
|
+
return attachContentLocales(parseMarkdownFile(fullPath, slug), slug);
|
|
468
505
|
} catch {
|
|
469
506
|
return null;
|
|
470
507
|
}
|
|
@@ -473,11 +510,21 @@ export function getPageBySlug(slug: string): PostData | null {
|
|
|
473
510
|
export function getAllPages(): PostData[] {
|
|
474
511
|
const items = fs.readdirSync(pagesDirectory, { withFileTypes: true });
|
|
475
512
|
return items
|
|
476
|
-
.filter(item =>
|
|
513
|
+
.filter(item => {
|
|
514
|
+
if (!item.isFile()) return false;
|
|
515
|
+
if (!item.name.endsWith('.mdx') && !item.name.endsWith('.md')) return false;
|
|
516
|
+
// Exclude locale variant files (e.g. about.zh.mdx, about.en.mdx) — they are not standalone routes
|
|
517
|
+
const base = item.name.replace(/\.mdx?$/, '');
|
|
518
|
+
const parts = base.split('.');
|
|
519
|
+
if (parts.length > 1 && siteConfig.i18n.locales.includes(parts[parts.length - 1])) {
|
|
520
|
+
return false;
|
|
521
|
+
}
|
|
522
|
+
return true;
|
|
523
|
+
})
|
|
477
524
|
.map(item => {
|
|
478
525
|
const slug = item.name.replace(/\.mdx?$/, '');
|
|
479
526
|
const fullPath = path.join(pagesDirectory, item.name);
|
|
480
|
-
return parseMarkdownFile(fullPath, slug);
|
|
527
|
+
return attachContentLocales(parseMarkdownFile(fullPath, slug), slug);
|
|
481
528
|
});
|
|
482
529
|
}
|
|
483
530
|
|
|
@@ -503,6 +550,7 @@ export function getFlowTags(): Record<string, number> {
|
|
|
503
550
|
export function getAllTags(): Record<string, number> {
|
|
504
551
|
const allPosts = getAllPosts();
|
|
505
552
|
const allFlows = getAllFlows();
|
|
553
|
+
const allNotes = getAllNotes();
|
|
506
554
|
const tags: Record<string, number> = {};
|
|
507
555
|
|
|
508
556
|
allPosts.forEach((post) => {
|
|
@@ -519,6 +567,13 @@ export function getAllTags(): Record<string, number> {
|
|
|
519
567
|
});
|
|
520
568
|
});
|
|
521
569
|
|
|
570
|
+
allNotes.forEach((note) => {
|
|
571
|
+
note.tags.forEach((tag) => {
|
|
572
|
+
const normalizedTag = tag.toLowerCase();
|
|
573
|
+
tags[normalizedTag] = (tags[normalizedTag] || 0) + 1;
|
|
574
|
+
});
|
|
575
|
+
});
|
|
576
|
+
|
|
522
577
|
return tags;
|
|
523
578
|
}
|
|
524
579
|
|
|
@@ -658,6 +713,16 @@ export function getFeaturedPosts(): PostData[] {
|
|
|
658
713
|
return allPosts.filter(post => post.featured);
|
|
659
714
|
}
|
|
660
715
|
|
|
716
|
+
export function getAdjacentPosts(slug: string): { prev: PostData | null; next: PostData | null } {
|
|
717
|
+
const allPosts = getAllPosts(); // sorted desc by date (newest first)
|
|
718
|
+
const index = allPosts.findIndex(p => p.slug === slug);
|
|
719
|
+
if (index === -1) return { prev: null, next: null };
|
|
720
|
+
return {
|
|
721
|
+
prev: index < allPosts.length - 1 ? allPosts[index + 1] : null, // older post
|
|
722
|
+
next: index > 0 ? allPosts[index - 1] : null, // newer post
|
|
723
|
+
};
|
|
724
|
+
}
|
|
725
|
+
|
|
661
726
|
export function getFeaturedSeries(): Record<string, PostData[]> {
|
|
662
727
|
const allSeries = getAllSeries();
|
|
663
728
|
const featuredSeries: Record<string, PostData[]> = {};
|
|
@@ -909,7 +974,7 @@ export function getBooksByAuthor(author: string): BookData[] {
|
|
|
909
974
|
// ─── Flows (Daily Notes) ────────────────────────────────────────────────────
|
|
910
975
|
|
|
911
976
|
const FlowSchema = z.object({
|
|
912
|
-
title: z.string(),
|
|
977
|
+
title: z.string().optional(),
|
|
913
978
|
date: z.union([z.string(), z.date()]).transform(val => new Date(val).toISOString().split('T')[0]).optional(),
|
|
914
979
|
tags: z.array(z.string()).optional().default([]),
|
|
915
980
|
draft: z.boolean().optional().default(false),
|
|
@@ -945,7 +1010,7 @@ function parseFlowFile(fullPath: string, slug: string): FlowData {
|
|
|
945
1010
|
return {
|
|
946
1011
|
slug,
|
|
947
1012
|
date,
|
|
948
|
-
title: data.title,
|
|
1013
|
+
title: data.title ?? date, // fall back to date string if no title in frontmatter
|
|
949
1014
|
tags: data.tags,
|
|
950
1015
|
draft: data.draft,
|
|
951
1016
|
content: contentWithoutH1,
|
|
@@ -1001,7 +1066,7 @@ export function getAllFlows(): FlowData[] {
|
|
|
1001
1066
|
return flows
|
|
1002
1067
|
.filter(flow => {
|
|
1003
1068
|
if (process.env.NODE_ENV === 'production' && flow.draft) return false;
|
|
1004
|
-
if (!siteConfig.showFuturePosts) {
|
|
1069
|
+
if (!siteConfig.posts?.showFuturePosts) {
|
|
1005
1070
|
const flowDate = new Date(flow.date);
|
|
1006
1071
|
const now = new Date();
|
|
1007
1072
|
if (flowDate > now) return false;
|
|
@@ -1065,3 +1130,267 @@ export function getAdjacentFlows(slug: string): { prev: FlowData | null; next: F
|
|
|
1065
1130
|
export function getRecentFlows(limit: number = 5): FlowData[] {
|
|
1066
1131
|
return getAllFlows().slice(0, limit);
|
|
1067
1132
|
}
|
|
1133
|
+
|
|
1134
|
+
// ─── Notes (Knowledge Base) ──────────────────────────────────────────────────
|
|
1135
|
+
|
|
1136
|
+
const NoteSchema = z.object({
|
|
1137
|
+
title: z.string(),
|
|
1138
|
+
date: z.union([z.string(), z.date()]).transform(val => new Date(val).toISOString().split('T')[0]).optional(),
|
|
1139
|
+
tags: z.array(z.string()).optional().default([]),
|
|
1140
|
+
draft: z.boolean().optional().default(false),
|
|
1141
|
+
aliases: z.array(z.string()).optional().default([]),
|
|
1142
|
+
toc: z.boolean().optional().default(true),
|
|
1143
|
+
backlinks: z.boolean().optional().default(true),
|
|
1144
|
+
});
|
|
1145
|
+
|
|
1146
|
+
export interface NoteData {
|
|
1147
|
+
slug: string;
|
|
1148
|
+
title: string;
|
|
1149
|
+
date: string;
|
|
1150
|
+
tags: string[];
|
|
1151
|
+
draft: boolean;
|
|
1152
|
+
aliases: string[];
|
|
1153
|
+
toc: boolean;
|
|
1154
|
+
backlinks: boolean;
|
|
1155
|
+
content: string;
|
|
1156
|
+
excerpt: string;
|
|
1157
|
+
headings: Heading[];
|
|
1158
|
+
readingTime: string;
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
function parseNoteFile(fullPath: string, slug: string): NoteData {
|
|
1162
|
+
const fileContents = fs.readFileSync(fullPath, 'utf8');
|
|
1163
|
+
const { data: rawData, content } = matter(fileContents);
|
|
1164
|
+
|
|
1165
|
+
const parsed = NoteSchema.safeParse(rawData);
|
|
1166
|
+
if (!parsed.success) {
|
|
1167
|
+
console.error(`Invalid note frontmatter in ${fullPath}:`, parsed.error.format());
|
|
1168
|
+
throw new Error(`Invalid note frontmatter in ${fullPath}`);
|
|
1169
|
+
}
|
|
1170
|
+
const data = parsed.data;
|
|
1171
|
+
|
|
1172
|
+
const contentWithoutH1 = content.replace(/^\s*#\s+[^\n]+/, '').trim();
|
|
1173
|
+
const date = data.date || fs.statSync(fullPath).mtime.toISOString().split('T')[0];
|
|
1174
|
+
const excerpt = generateExcerpt(contentWithoutH1);
|
|
1175
|
+
const headings = getHeadings(content);
|
|
1176
|
+
const readingTime = calculateReadingTime(contentWithoutH1);
|
|
1177
|
+
|
|
1178
|
+
return {
|
|
1179
|
+
slug,
|
|
1180
|
+
title: data.title,
|
|
1181
|
+
date,
|
|
1182
|
+
tags: data.tags,
|
|
1183
|
+
draft: data.draft,
|
|
1184
|
+
aliases: data.aliases,
|
|
1185
|
+
toc: data.toc,
|
|
1186
|
+
backlinks: data.backlinks,
|
|
1187
|
+
content: contentWithoutH1,
|
|
1188
|
+
excerpt,
|
|
1189
|
+
headings,
|
|
1190
|
+
readingTime,
|
|
1191
|
+
};
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
let _allNotes: NoteData[] | null = null;
|
|
1195
|
+
|
|
1196
|
+
export function getAllNotes(): NoteData[] {
|
|
1197
|
+
if (_allNotes && process.env.NODE_ENV === 'production') return _allNotes;
|
|
1198
|
+
|
|
1199
|
+
if (!fs.existsSync(notesDirectory)) {
|
|
1200
|
+
_allNotes = [];
|
|
1201
|
+
return _allNotes;
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
const notes: NoteData[] = [];
|
|
1205
|
+
const items = fs.readdirSync(notesDirectory, { withFileTypes: true });
|
|
1206
|
+
|
|
1207
|
+
for (const item of items) {
|
|
1208
|
+
if (!item.isFile()) continue;
|
|
1209
|
+
if (!item.name.endsWith('.md') && !item.name.endsWith('.mdx')) continue;
|
|
1210
|
+
const slug = item.name.replace(/\.mdx?$/, '');
|
|
1211
|
+
const fullPath = path.join(notesDirectory, item.name);
|
|
1212
|
+
try {
|
|
1213
|
+
notes.push(parseNoteFile(fullPath, slug));
|
|
1214
|
+
} catch (e) {
|
|
1215
|
+
console.error(`Error parsing note ${fullPath}:`, e);
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
_allNotes = notes
|
|
1220
|
+
.filter(note => process.env.NODE_ENV !== 'production' || !note.draft)
|
|
1221
|
+
.sort((a, b) => (a.date < b.date ? 1 : -1));
|
|
1222
|
+
|
|
1223
|
+
return _allNotes;
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
export function getNoteBySlug(slug: string): NoteData | null {
|
|
1227
|
+
if (!fs.existsSync(notesDirectory)) return null;
|
|
1228
|
+
|
|
1229
|
+
const mdxPath = path.join(notesDirectory, `${slug}.mdx`);
|
|
1230
|
+
const mdPath = path.join(notesDirectory, `${slug}.md`);
|
|
1231
|
+
|
|
1232
|
+
let fullPath = '';
|
|
1233
|
+
if (fs.existsSync(mdxPath)) fullPath = mdxPath;
|
|
1234
|
+
else if (fs.existsSync(mdPath)) fullPath = mdPath;
|
|
1235
|
+
else return null;
|
|
1236
|
+
|
|
1237
|
+
try {
|
|
1238
|
+
const note = parseNoteFile(fullPath, slug);
|
|
1239
|
+
if (process.env.NODE_ENV === 'production' && note.draft) return null;
|
|
1240
|
+
return note;
|
|
1241
|
+
} catch {
|
|
1242
|
+
return null;
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
export function getAdjacentNotes(slug: string): { prev: NoteData | null; next: NoteData | null } {
|
|
1247
|
+
const allNotes = getAllNotes(); // sorted newest-first
|
|
1248
|
+
const index = allNotes.findIndex(n => n.slug === slug);
|
|
1249
|
+
if (index === -1) return { prev: null, next: null };
|
|
1250
|
+
return {
|
|
1251
|
+
prev: index < allNotes.length - 1 ? allNotes[index + 1] : null, // older
|
|
1252
|
+
next: index > 0 ? allNotes[index - 1] : null, // newer
|
|
1253
|
+
};
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
export function getRecentNotes(limit: number = 5): NoteData[] {
|
|
1257
|
+
return getAllNotes().slice(0, limit);
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
export function getNoteTags(): Record<string, number> {
|
|
1261
|
+
const tags: Record<string, number> = {};
|
|
1262
|
+
getAllNotes().forEach(note => {
|
|
1263
|
+
note.tags.forEach(tag => {
|
|
1264
|
+
const normalized = tag.toLowerCase();
|
|
1265
|
+
tags[normalized] = (tags[normalized] || 0) + 1;
|
|
1266
|
+
});
|
|
1267
|
+
});
|
|
1268
|
+
return tags;
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
export function getNotesByTag(tag: string): NoteData[] {
|
|
1272
|
+
return getAllNotes().filter(n =>
|
|
1273
|
+
n.tags.map(t => t.toLowerCase()).includes(tag.toLowerCase())
|
|
1274
|
+
);
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
// ─── Slug Registry ───────────────────────────────────────────────────────────
|
|
1278
|
+
|
|
1279
|
+
export interface SlugRegistryEntry {
|
|
1280
|
+
url: string;
|
|
1281
|
+
type: 'post' | 'note' | 'flow' | 'series';
|
|
1282
|
+
title: string;
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
let _slugRegistry: Map<string, SlugRegistryEntry> | null = null;
|
|
1286
|
+
|
|
1287
|
+
export function buildSlugRegistry(): Map<string, SlugRegistryEntry> {
|
|
1288
|
+
if (_slugRegistry && process.env.NODE_ENV === 'production') return _slugRegistry;
|
|
1289
|
+
|
|
1290
|
+
const map = new Map<string, SlugRegistryEntry>();
|
|
1291
|
+
|
|
1292
|
+
getAllPosts().forEach(p =>
|
|
1293
|
+
map.set(p.slug, { url: `/posts/${p.slug}`, type: 'post', title: p.title })
|
|
1294
|
+
);
|
|
1295
|
+
|
|
1296
|
+
getAllFlows().forEach(f =>
|
|
1297
|
+
map.set(f.slug, { url: `/flows/${f.slug}`, type: 'flow', title: f.title })
|
|
1298
|
+
);
|
|
1299
|
+
|
|
1300
|
+
getAllNotes().forEach(n => {
|
|
1301
|
+
if (map.has(n.slug)) {
|
|
1302
|
+
console.warn(`[slugRegistry] Note slug "${n.slug}" conflicts with an existing entry.`);
|
|
1303
|
+
}
|
|
1304
|
+
map.set(n.slug, { url: `/notes/${n.slug}`, type: 'note', title: n.title });
|
|
1305
|
+
n.aliases.forEach(a => {
|
|
1306
|
+
if (map.has(a)) {
|
|
1307
|
+
console.warn(`[slugRegistry] Note alias "${a}" (→ ${n.slug}) conflicts with existing slug; skipping.`);
|
|
1308
|
+
} else {
|
|
1309
|
+
map.set(a, { url: `/notes/${n.slug}`, type: 'note', title: n.title });
|
|
1310
|
+
}
|
|
1311
|
+
});
|
|
1312
|
+
});
|
|
1313
|
+
|
|
1314
|
+
if (fs.existsSync(seriesDirectory)) {
|
|
1315
|
+
fs.readdirSync(seriesDirectory, { withFileTypes: true }).forEach(entry => {
|
|
1316
|
+
if (!entry.isDirectory()) return;
|
|
1317
|
+
const slug = entry.name;
|
|
1318
|
+
const seriesData = getSeriesData(slug);
|
|
1319
|
+
map.set(slug, {
|
|
1320
|
+
url: `/series/${slug}`,
|
|
1321
|
+
type: 'series',
|
|
1322
|
+
title: seriesData?.title || slug,
|
|
1323
|
+
});
|
|
1324
|
+
});
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
_slugRegistry = map;
|
|
1328
|
+
return map;
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
// ─── Backlink Index ──────────────────────────────────────────────────────────
|
|
1332
|
+
|
|
1333
|
+
export interface BacklinkSource {
|
|
1334
|
+
slug: string;
|
|
1335
|
+
title: string;
|
|
1336
|
+
type: 'post' | 'note' | 'flow' | 'series';
|
|
1337
|
+
url: string;
|
|
1338
|
+
context: string;
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
function extractWikilinkContext(text: string, matchStart: number, matchEnd: number): string {
|
|
1342
|
+
const RADIUS = 120;
|
|
1343
|
+
const start = Math.max(0, matchStart - RADIUS);
|
|
1344
|
+
const end = Math.min(text.length, matchEnd + RADIUS);
|
|
1345
|
+
let ctx = text.slice(start, end);
|
|
1346
|
+
|
|
1347
|
+
// Replace wikilinks in context with just display text for readability
|
|
1348
|
+
ctx = ctx.replace(/\[\[([^\]|]+?)(?:\|([^\]]+?))?\]\]/g, (_, slug, display) => display || slug);
|
|
1349
|
+
|
|
1350
|
+
if (start > 0) ctx = ctx.replace(/^[^\s.!?]{1,30}/, '').trimStart();
|
|
1351
|
+
if (end < text.length) ctx = ctx.replace(/[^\s.!?]{1,30}$/, '').trimEnd();
|
|
1352
|
+
|
|
1353
|
+
return ctx.trim().slice(0, 200);
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
function buildBacklinkIndex(): Map<string, BacklinkSource[]> {
|
|
1357
|
+
const index = new Map<string, BacklinkSource[]>();
|
|
1358
|
+
|
|
1359
|
+
const addBacklinks = (
|
|
1360
|
+
content: string,
|
|
1361
|
+
sourceSlug: string,
|
|
1362
|
+
sourceTitle: string,
|
|
1363
|
+
sourceType: BacklinkSource['type'],
|
|
1364
|
+
sourceUrl: string
|
|
1365
|
+
) => {
|
|
1366
|
+
// Create a fresh RegExp per call to avoid lastIndex issues with 'g' flag
|
|
1367
|
+
const WIKILINK = /\[\[([^\]|]+?)(?:\|([^\]]+?))?\]\]/g;
|
|
1368
|
+
let match;
|
|
1369
|
+
while ((match = WIKILINK.exec(content)) !== null) {
|
|
1370
|
+
const targetSlug = match[1].trim();
|
|
1371
|
+
if (targetSlug === sourceSlug) continue; // skip self-references
|
|
1372
|
+
const context = extractWikilinkContext(content, match.index, match.index + match[0].length);
|
|
1373
|
+
let sources = index.get(targetSlug);
|
|
1374
|
+
if (!sources) {
|
|
1375
|
+
sources = [];
|
|
1376
|
+
index.set(targetSlug, sources);
|
|
1377
|
+
}
|
|
1378
|
+
if (!sources.some(b => b.slug === sourceSlug && b.type === sourceType)) {
|
|
1379
|
+
sources.push({ slug: sourceSlug, title: sourceTitle, type: sourceType, url: sourceUrl, context });
|
|
1380
|
+
}
|
|
1381
|
+
}
|
|
1382
|
+
};
|
|
1383
|
+
|
|
1384
|
+
getAllPosts().forEach(p => addBacklinks(p.content, p.slug, p.title, 'post', `/posts/${p.slug}`));
|
|
1385
|
+
getAllNotes().forEach(n => addBacklinks(n.content, n.slug, n.title, 'note', `/notes/${n.slug}`));
|
|
1386
|
+
getAllFlows().forEach(f => addBacklinks(f.content, f.slug, f.title, 'flow', `/flows/${f.slug}`));
|
|
1387
|
+
|
|
1388
|
+
return index;
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
let _backlinkIndex: Map<string, BacklinkSource[]> | null = null;
|
|
1392
|
+
|
|
1393
|
+
export function getBacklinks(slug: string): BacklinkSource[] {
|
|
1394
|
+
if (!_backlinkIndex || process.env.NODE_ENV !== 'production') _backlinkIndex = buildBacklinkIndex();
|
|
1395
|
+
return _backlinkIndex.get(slug) ?? [];
|
|
1396
|
+
}
|