@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.
Files changed (92) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/GEMINI.md +12 -2
  3. package/README.md +14 -0
  4. package/TODO.md +24 -16
  5. package/bun.lock +8 -3
  6. package/content/about.mdx +1 -0
  7. package/content/about.zh.mdx +21 -0
  8. package/content/flows/2026/02/05.md +0 -1
  9. package/content/flows/2026/02/10.mdx +2 -1
  10. package/content/flows/2026/02/15.md +2 -1
  11. package/content/flows/2026/02/18.mdx +2 -1
  12. package/content/flows/2026/02/20.md +15 -0
  13. package/content/links.mdx +42 -0
  14. package/content/links.zh.mdx +41 -0
  15. package/content/notes/algorithms-and-data-structures.mdx +51 -0
  16. package/content/notes/digital-garden-philosophy.mdx +36 -0
  17. package/content/notes/react-server-components.mdx +49 -0
  18. package/content/notes/tailwind-v4.mdx +45 -0
  19. package/content/notes/zettelkasten-method.mdx +33 -0
  20. package/content/posts/2026-02-20-i18n-routing-considerations.mdx +150 -0
  21. package/content/posts/multimedia-showcase/index.mdx +261 -0
  22. package/content/privacy.mdx +32 -0
  23. package/content/privacy.zh.mdx +32 -0
  24. package/docs/ARCHITECTURE.md +16 -0
  25. package/docs/CONTRIBUTING.md +11 -0
  26. package/docs/DIGITAL_GARDEN.md +64 -0
  27. package/package.json +8 -3
  28. package/scripts/copy-assets.ts +1 -1
  29. package/scripts/generate-knowledge-graph.ts +162 -0
  30. package/scripts/new-flow.ts +0 -5
  31. package/scripts/new-note.ts +53 -0
  32. package/site.config.ts +146 -44
  33. package/src/app/[slug]/page.tsx +0 -10
  34. package/src/app/archive/page.tsx +38 -10
  35. package/src/app/books/[slug]/page.tsx +18 -0
  36. package/src/app/flows/[year]/[month]/[day]/page.tsx +51 -31
  37. package/src/app/flows/[year]/[month]/page.tsx +15 -13
  38. package/src/app/flows/[year]/page.tsx +22 -15
  39. package/src/app/flows/page/[page]/page.tsx +3 -9
  40. package/src/app/flows/page.tsx +3 -8
  41. package/src/app/globals.css +41 -0
  42. package/src/app/graph/page.tsx +19 -0
  43. package/src/app/layout.tsx +47 -21
  44. package/src/app/notes/[slug]/page.tsx +128 -0
  45. package/src/app/notes/page/[page]/page.tsx +58 -0
  46. package/src/app/notes/page.tsx +31 -0
  47. package/src/app/page.tsx +134 -72
  48. package/src/app/posts/[slug]/page.tsx +8 -12
  49. package/src/app/search.json/route.ts +15 -1
  50. package/src/app/series/[slug]/page.tsx +18 -0
  51. package/src/app/subscribe/page.tsx +17 -0
  52. package/src/app/tags/[tag]/page.tsx +9 -26
  53. package/src/app/tags/page.tsx +3 -8
  54. package/src/components/AuthorCard.tsx +43 -0
  55. package/src/components/Backlinks.tsx +39 -0
  56. package/src/components/Comments.tsx +20 -4
  57. package/src/components/ExternalLinks.tsx +6 -2
  58. package/src/components/FlowCalendarSidebar.tsx +4 -2
  59. package/src/components/FlowContent.tsx +4 -3
  60. package/src/components/FlowHubTabs.tsx +50 -0
  61. package/src/components/FlowTimelineEntry.tsx +7 -9
  62. package/src/components/Footer.tsx +35 -26
  63. package/src/components/KnowledgeGraph.tsx +324 -0
  64. package/src/components/LanguageProvider.tsx +0 -5
  65. package/src/components/LanguageSwitch.tsx +117 -6
  66. package/src/components/LocaleSwitch.tsx +33 -0
  67. package/src/components/MarkdownRenderer.tsx +13 -2
  68. package/src/components/Navbar.tsx +266 -17
  69. package/src/components/NoteContent.tsx +123 -0
  70. package/src/components/NoteSidebar.tsx +132 -0
  71. package/src/components/PostNavigation.tsx +55 -0
  72. package/src/components/PostSidebar.tsx +172 -126
  73. package/src/components/ReadingProgressBar.tsx +6 -21
  74. package/src/components/RecentNotesSection.tsx +6 -11
  75. package/src/components/RelatedPosts.tsx +1 -1
  76. package/src/components/Search.tsx +29 -5
  77. package/src/components/SelectedBooksSection.tsx +12 -6
  78. package/src/components/ShareBar.tsx +115 -0
  79. package/src/components/SimpleLayoutHeader.tsx +5 -14
  80. package/src/components/SubscribePage.tsx +298 -0
  81. package/src/components/TagContentTabs.tsx +102 -0
  82. package/src/components/TagPageHeader.tsx +7 -13
  83. package/src/components/TagSidebar.tsx +142 -0
  84. package/src/components/TagsIndexClient.tsx +156 -0
  85. package/src/hooks/useScrollY.ts +41 -0
  86. package/src/i18n/translations.ts +105 -1
  87. package/src/layouts/PostLayout.tsx +40 -8
  88. package/src/layouts/SimpleLayout.tsx +53 -15
  89. package/src/lib/markdown.ts +347 -18
  90. package/src/lib/remark-wikilinks.ts +59 -0
  91. package/src/lib/search-utils.ts +2 -1
  92. 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={`layout-container ${showSidebar ? 'lg:max-w-7xl' : 'lg:max-w-6xl'}`}>
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-12">
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
- <RelatedPosts posts={relatedPosts || []} />
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, titleOverride, subtitleOverride }: SimpleLayoutProps) {
15
- return (
16
- <div className="layout-main">
17
- <article className="max-w-3xl mx-auto">
18
- <SimpleLayoutHeader
19
- title={post.title}
20
- excerpt={post.excerpt}
21
- titleKey={titleKey}
22
- subtitleKey={subtitleKey}
23
- titleOverride={titleOverride}
24
- subtitleOverride={subtitleOverride}
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
- </article>
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
  }
@@ -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(/`[^`]*`/g, '');
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
- if (!fs.existsSync(fullPath)) {
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 => item.isFile() && (item.name.endsWith('.mdx') || item.name.endsWith('.md')))
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
+ }