@hutusi/amytis 1.7.0 → 1.9.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 (83) hide show
  1. package/.github/workflows/ci.yml +1 -1
  2. package/CHANGELOG.md +63 -0
  3. package/CLAUDE.md +9 -18
  4. package/GEMINI.md +6 -0
  5. package/README.md +44 -0
  6. package/TODO.md +15 -3
  7. package/bun.lock +5 -3
  8. package/content/about.mdx +64 -10
  9. package/content/about.zh.mdx +66 -9
  10. package/content/books/sample-book/index.mdx +3 -3
  11. package/content/flows/2026/02/05.md +0 -1
  12. package/content/flows/2026/02/10.mdx +2 -1
  13. package/content/flows/2026/02/15.md +2 -1
  14. package/content/flows/2026/02/18.mdx +2 -1
  15. package/content/flows/2026/02/20.md +0 -1
  16. package/content/notes/algorithms-and-data-structures.mdx +51 -0
  17. package/content/notes/digital-garden-philosophy.mdx +36 -0
  18. package/content/notes/react-server-components.mdx +49 -0
  19. package/content/notes/tailwind-v4.mdx +45 -0
  20. package/content/notes/zettelkasten-method.mdx +33 -0
  21. package/content/series/digital-garden/01-philosophy.mdx +25 -12
  22. package/docs/ARCHITECTURE.md +9 -1
  23. package/docs/CONTRIBUTING.md +26 -0
  24. package/docs/DIGITAL_GARDEN.md +72 -0
  25. package/imports/README.md +45 -0
  26. package/package.json +12 -5
  27. package/scripts/generate-knowledge-graph.ts +162 -0
  28. package/scripts/import-book.ts +176 -0
  29. package/scripts/new-flow-from-chat.ts +238 -0
  30. package/scripts/new-flow.ts +0 -5
  31. package/scripts/new-note.ts +53 -0
  32. package/scripts/sync-book-chapters.ts +210 -0
  33. package/site.config.ts +30 -7
  34. package/src/app/authors/[author]/page.tsx +3 -1
  35. package/src/app/books/[slug]/[chapter]/page.tsx +2 -1
  36. package/src/app/books/[slug]/page.tsx +6 -5
  37. package/src/app/flows/[year]/[month]/[day]/page.tsx +35 -29
  38. package/src/app/flows/[year]/[month]/page.tsx +18 -13
  39. package/src/app/flows/[year]/page.tsx +25 -15
  40. package/src/app/flows/page/[page]/page.tsx +5 -9
  41. package/src/app/flows/page.tsx +5 -8
  42. package/src/app/globals.css +41 -0
  43. package/src/app/graph/page.tsx +21 -0
  44. package/src/app/layout.tsx +4 -2
  45. package/src/app/notes/[slug]/page.tsx +129 -0
  46. package/src/app/notes/page/[page]/page.tsx +60 -0
  47. package/src/app/notes/page.tsx +33 -0
  48. package/src/app/page/[page]/page.tsx +1 -0
  49. package/src/app/page.tsx +4 -5
  50. package/src/app/posts/[slug]/page.tsx +5 -2
  51. package/src/app/posts/page/[page]/page.tsx +4 -1
  52. package/src/app/search.json/route.ts +17 -3
  53. package/src/app/series/[slug]/page/[page]/page.tsx +1 -0
  54. package/src/app/series/[slug]/page.tsx +3 -3
  55. package/src/app/sitemap.ts +1 -1
  56. package/src/app/tags/[tag]/page.tsx +3 -3
  57. package/src/components/Backlinks.tsx +39 -0
  58. package/src/components/BookMobileNav.tsx +11 -11
  59. package/src/components/BookSidebar.tsx +17 -25
  60. package/src/components/BrowserDetectionBanner.tsx +96 -0
  61. package/src/components/FeaturedStoriesSection.tsx +1 -1
  62. package/src/components/FlowCalendarSidebar.tsx +4 -2
  63. package/src/components/FlowContent.tsx +4 -3
  64. package/src/components/FlowHubTabs.tsx +50 -0
  65. package/src/components/FlowTimelineEntry.tsx +7 -9
  66. package/src/components/KnowledgeGraph.tsx +324 -0
  67. package/src/components/LanguageProvider.tsx +14 -5
  68. package/src/components/MarkdownRenderer.tsx +13 -2
  69. package/src/components/Navbar.tsx +237 -10
  70. package/src/components/NoteContent.tsx +123 -0
  71. package/src/components/NoteSidebar.tsx +132 -0
  72. package/src/components/RecentNotesSection.tsx +6 -11
  73. package/src/components/Search.tsx +7 -3
  74. package/src/components/TagContentTabs.tsx +0 -1
  75. package/src/i18n/translations.ts +43 -17
  76. package/src/layouts/BookLayout.tsx +3 -3
  77. package/src/layouts/PostLayout.tsx +8 -3
  78. package/src/lib/i18n.ts +83 -6
  79. package/src/lib/markdown.ts +306 -19
  80. package/src/lib/remark-wikilinks.ts +59 -0
  81. package/src/lib/search-utils.ts +2 -1
  82. package/tests/unit/static-params.test.ts +238 -0
  83. package/content/series/digital-garden/01-philosophy/index.mdx +0 -23
@@ -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(),
@@ -98,7 +99,7 @@ export function generateExcerpt(content: string): string {
98
99
  plain = plain.replace(/!\[[^\]]*\]\([^)]+\)/g, '');
99
100
  plain = plain.replace(/\*\[([^\]]+)\*\]\([^)]+\)/g, '$1');
100
101
  plain = plain.replace(/(\$\*\*|__|\*|_)/g, '');
101
- plain = plain.replace(/`[^`]*`/g, '');
102
+ plain = plain.replace(/`([^`]+)`/g, '$1');
102
103
  plain = plain.replace(/^>\s+/gm, '');
103
104
  plain = plain.replace(/\s+/g, ' ').trim();
104
105
 
@@ -549,6 +550,7 @@ export function getFlowTags(): Record<string, number> {
549
550
  export function getAllTags(): Record<string, number> {
550
551
  const allPosts = getAllPosts();
551
552
  const allFlows = getAllFlows();
553
+ const allNotes = getAllNotes();
552
554
  const tags: Record<string, number> = {};
553
555
 
554
556
  allPosts.forEach((post) => {
@@ -565,6 +567,13 @@ export function getAllTags(): Record<string, number> {
565
567
  });
566
568
  });
567
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
+
568
577
  return tags;
569
578
  }
570
579
 
@@ -577,7 +586,12 @@ export function getPostsByAuthor(author: string): PostData[] {
577
586
 
578
587
  export function getAuthorSlug(author: string): string {
579
588
  const slugger = new GithubSlugger();
580
- return slugger.slug(author.trim());
589
+ // Normalize all Unicode dash punctuation to ASCII hyphen, then trim edges.
590
+ // This avoids runtime-specific outputs like wrapped dash variants.
591
+ return slugger
592
+ .slug(author.trim())
593
+ .replace(/[\p{Dash_Punctuation}]+/gu, '-')
594
+ .replace(/^-+|-+$/g, '');
581
595
  }
582
596
 
583
597
  export function getAllAuthors(): Record<string, number> {
@@ -745,15 +759,15 @@ export function getSeriesData(slug: string): PostData | null {
745
759
 
746
760
  export interface BookChapterEntry {
747
761
  title: string;
748
- file: string;
762
+ id: string;
749
763
  part?: string;
750
764
  }
751
765
 
752
766
  export interface BookTocPart {
753
767
  part: string;
754
- chapters: { title: string; file: string }[];
768
+ chapters: { title: string; id: string }[];
755
769
  }
756
- export type BookTocItem = BookTocPart | { title: string; file: string };
770
+ export type BookTocItem = BookTocPart | { title: string; id: string };
757
771
 
758
772
  export interface BookData {
759
773
  title: string;
@@ -778,13 +792,14 @@ export interface BookChapterData {
778
792
  excerpt?: string;
779
793
  latex: boolean;
780
794
  readingTime: string;
781
- prevChapter: { title: string; file: string } | null;
782
- nextChapter: { title: string; file: string } | null;
795
+ isFolder: boolean;
796
+ prevChapter: { title: string; id: string } | null;
797
+ nextChapter: { title: string; id: string } | null;
783
798
  }
784
799
 
785
800
  const BookChapterRefSchema = z.object({
786
801
  title: z.string(),
787
- file: z.string(),
802
+ id: z.string(),
788
803
  });
789
804
 
790
805
  const BookTocItemSchema: z.ZodType<BookTocItem> = z.union([
@@ -818,10 +833,10 @@ function flattenBookChapters(toc: BookTocItem[]): BookChapterEntry[] {
818
833
  for (const item of toc) {
819
834
  if ('part' in item) {
820
835
  for (const ch of item.chapters) {
821
- result.push({ title: ch.title, file: ch.file, part: item.part });
836
+ result.push({ title: ch.title, id: ch.id, part: item.part });
822
837
  }
823
838
  } else {
824
- result.push({ title: item.title, file: item.file });
839
+ result.push({ title: item.title, id: item.id });
825
840
  }
826
841
  }
827
842
  return result;
@@ -852,10 +867,12 @@ export function getBookData(slug: string): BookData | null {
852
867
  // Warn about missing chapter files
853
868
  const chapters = flattenBookChapters(data.chapters);
854
869
  for (const ch of chapters) {
855
- const chMdx = path.join(bookDir, `${ch.file}.mdx`);
856
- const chMd = path.join(bookDir, `${ch.file}.md`);
857
- if (!fs.existsSync(chMdx) && !fs.existsSync(chMd)) {
858
- console.warn(`Book "${slug}": chapter file "${ch.file}" not found`);
870
+ const chMdx = path.join(bookDir, `${ch.id}.mdx`);
871
+ const chMd = path.join(bookDir, `${ch.id}.md`);
872
+ const chFolderMdx = path.join(bookDir, ch.id, 'index.mdx');
873
+ const chFolderMd = path.join(bookDir, ch.id, 'index.md');
874
+ if (!fs.existsSync(chMdx) && !fs.existsSync(chMd) && !fs.existsSync(chFolderMdx) && !fs.existsSync(chFolderMd)) {
875
+ console.warn(`Book "${slug}": chapter "${ch.id}" not found`);
859
876
  }
860
877
  }
861
878
 
@@ -892,9 +909,14 @@ export function getBookChapter(bookSlug: string, chapterSlug: string): BookChapt
892
909
  const bookDir = path.join(booksDirectory, bookSlug);
893
910
  const chMdx = path.join(bookDir, `${chapterSlug}.mdx`);
894
911
  const chMd = path.join(bookDir, `${chapterSlug}.md`);
912
+ const chFolderMdx = path.join(bookDir, chapterSlug, 'index.mdx');
913
+ const chFolderMd = path.join(bookDir, chapterSlug, 'index.md');
895
914
  let fullPath = '';
915
+ let isFolder = false;
896
916
  if (fs.existsSync(chMdx)) fullPath = chMdx;
897
917
  else if (fs.existsSync(chMd)) fullPath = chMd;
918
+ else if (fs.existsSync(chFolderMdx)) { fullPath = chFolderMdx; isFolder = true; }
919
+ else if (fs.existsSync(chFolderMd)) { fullPath = chFolderMd; isFolder = true; }
898
920
  else return null;
899
921
 
900
922
  const fileContents = fs.readFileSync(fullPath, 'utf8');
@@ -917,7 +939,7 @@ export function getBookChapter(bookSlug: string, chapterSlug: string): BookChapt
917
939
  const excerpt = data.excerpt || generateExcerpt(contentWithoutH1);
918
940
 
919
941
  // Find prev/next
920
- const chapterIndex = book.chapters.findIndex(ch => ch.file === chapterSlug);
942
+ const chapterIndex = book.chapters.findIndex(ch => ch.id === chapterSlug);
921
943
  const prevChapter = chapterIndex > 0 ? book.chapters[chapterIndex - 1] : null;
922
944
  const nextChapter = chapterIndex < book.chapters.length - 1 ? book.chapters[chapterIndex + 1] : null;
923
945
 
@@ -930,8 +952,9 @@ export function getBookChapter(bookSlug: string, chapterSlug: string): BookChapt
930
952
  excerpt,
931
953
  latex: data.latex,
932
954
  readingTime,
933
- prevChapter: prevChapter ? { title: prevChapter.title, file: prevChapter.file } : null,
934
- nextChapter: nextChapter ? { title: nextChapter.title, file: nextChapter.file } : null,
955
+ isFolder,
956
+ prevChapter: prevChapter ? { title: prevChapter.title, id: prevChapter.id } : null,
957
+ nextChapter: nextChapter ? { title: nextChapter.title, id: nextChapter.id } : null,
935
958
  };
936
959
  }
937
960
 
@@ -965,7 +988,7 @@ export function getBooksByAuthor(author: string): BookData[] {
965
988
  // ─── Flows (Daily Notes) ────────────────────────────────────────────────────
966
989
 
967
990
  const FlowSchema = z.object({
968
- title: z.string(),
991
+ title: z.string().optional(),
969
992
  date: z.union([z.string(), z.date()]).transform(val => new Date(val).toISOString().split('T')[0]).optional(),
970
993
  tags: z.array(z.string()).optional().default([]),
971
994
  draft: z.boolean().optional().default(false),
@@ -1001,7 +1024,7 @@ function parseFlowFile(fullPath: string, slug: string): FlowData {
1001
1024
  return {
1002
1025
  slug,
1003
1026
  date,
1004
- title: data.title,
1027
+ title: data.title ?? date, // fall back to date string if no title in frontmatter
1005
1028
  tags: data.tags,
1006
1029
  draft: data.draft,
1007
1030
  content: contentWithoutH1,
@@ -1121,3 +1144,267 @@ export function getAdjacentFlows(slug: string): { prev: FlowData | null; next: F
1121
1144
  export function getRecentFlows(limit: number = 5): FlowData[] {
1122
1145
  return getAllFlows().slice(0, limit);
1123
1146
  }
1147
+
1148
+ // ─── Notes (Knowledge Base) ──────────────────────────────────────────────────
1149
+
1150
+ const NoteSchema = z.object({
1151
+ title: z.string(),
1152
+ date: z.union([z.string(), z.date()]).transform(val => new Date(val).toISOString().split('T')[0]).optional(),
1153
+ tags: z.array(z.string()).optional().default([]),
1154
+ draft: z.boolean().optional().default(false),
1155
+ aliases: z.array(z.string()).optional().default([]),
1156
+ toc: z.boolean().optional().default(true),
1157
+ backlinks: z.boolean().optional().default(true),
1158
+ });
1159
+
1160
+ export interface NoteData {
1161
+ slug: string;
1162
+ title: string;
1163
+ date: string;
1164
+ tags: string[];
1165
+ draft: boolean;
1166
+ aliases: string[];
1167
+ toc: boolean;
1168
+ backlinks: boolean;
1169
+ content: string;
1170
+ excerpt: string;
1171
+ headings: Heading[];
1172
+ readingTime: string;
1173
+ }
1174
+
1175
+ function parseNoteFile(fullPath: string, slug: string): NoteData {
1176
+ const fileContents = fs.readFileSync(fullPath, 'utf8');
1177
+ const { data: rawData, content } = matter(fileContents);
1178
+
1179
+ const parsed = NoteSchema.safeParse(rawData);
1180
+ if (!parsed.success) {
1181
+ console.error(`Invalid note frontmatter in ${fullPath}:`, parsed.error.format());
1182
+ throw new Error(`Invalid note frontmatter in ${fullPath}`);
1183
+ }
1184
+ const data = parsed.data;
1185
+
1186
+ const contentWithoutH1 = content.replace(/^\s*#\s+[^\n]+/, '').trim();
1187
+ const date = data.date || fs.statSync(fullPath).mtime.toISOString().split('T')[0];
1188
+ const excerpt = generateExcerpt(contentWithoutH1);
1189
+ const headings = getHeadings(content);
1190
+ const readingTime = calculateReadingTime(contentWithoutH1);
1191
+
1192
+ return {
1193
+ slug,
1194
+ title: data.title,
1195
+ date,
1196
+ tags: data.tags,
1197
+ draft: data.draft,
1198
+ aliases: data.aliases,
1199
+ toc: data.toc,
1200
+ backlinks: data.backlinks,
1201
+ content: contentWithoutH1,
1202
+ excerpt,
1203
+ headings,
1204
+ readingTime,
1205
+ };
1206
+ }
1207
+
1208
+ let _allNotes: NoteData[] | null = null;
1209
+
1210
+ export function getAllNotes(): NoteData[] {
1211
+ if (_allNotes && process.env.NODE_ENV === 'production') return _allNotes;
1212
+
1213
+ if (!fs.existsSync(notesDirectory)) {
1214
+ _allNotes = [];
1215
+ return _allNotes;
1216
+ }
1217
+
1218
+ const notes: NoteData[] = [];
1219
+ const items = fs.readdirSync(notesDirectory, { withFileTypes: true });
1220
+
1221
+ for (const item of items) {
1222
+ if (!item.isFile()) continue;
1223
+ if (!item.name.endsWith('.md') && !item.name.endsWith('.mdx')) continue;
1224
+ const slug = item.name.replace(/\.mdx?$/, '');
1225
+ const fullPath = path.join(notesDirectory, item.name);
1226
+ try {
1227
+ notes.push(parseNoteFile(fullPath, slug));
1228
+ } catch (e) {
1229
+ console.error(`Error parsing note ${fullPath}:`, e);
1230
+ }
1231
+ }
1232
+
1233
+ _allNotes = notes
1234
+ .filter(note => process.env.NODE_ENV !== 'production' || !note.draft)
1235
+ .sort((a, b) => (a.date < b.date ? 1 : -1));
1236
+
1237
+ return _allNotes;
1238
+ }
1239
+
1240
+ export function getNoteBySlug(slug: string): NoteData | null {
1241
+ if (!fs.existsSync(notesDirectory)) return null;
1242
+
1243
+ const mdxPath = path.join(notesDirectory, `${slug}.mdx`);
1244
+ const mdPath = path.join(notesDirectory, `${slug}.md`);
1245
+
1246
+ let fullPath = '';
1247
+ if (fs.existsSync(mdxPath)) fullPath = mdxPath;
1248
+ else if (fs.existsSync(mdPath)) fullPath = mdPath;
1249
+ else return null;
1250
+
1251
+ try {
1252
+ const note = parseNoteFile(fullPath, slug);
1253
+ if (process.env.NODE_ENV === 'production' && note.draft) return null;
1254
+ return note;
1255
+ } catch {
1256
+ return null;
1257
+ }
1258
+ }
1259
+
1260
+ export function getAdjacentNotes(slug: string): { prev: NoteData | null; next: NoteData | null } {
1261
+ const allNotes = getAllNotes(); // sorted newest-first
1262
+ const index = allNotes.findIndex(n => n.slug === slug);
1263
+ if (index === -1) return { prev: null, next: null };
1264
+ return {
1265
+ prev: index < allNotes.length - 1 ? allNotes[index + 1] : null, // older
1266
+ next: index > 0 ? allNotes[index - 1] : null, // newer
1267
+ };
1268
+ }
1269
+
1270
+ export function getRecentNotes(limit: number = 5): NoteData[] {
1271
+ return getAllNotes().slice(0, limit);
1272
+ }
1273
+
1274
+ export function getNoteTags(): Record<string, number> {
1275
+ const tags: Record<string, number> = {};
1276
+ getAllNotes().forEach(note => {
1277
+ note.tags.forEach(tag => {
1278
+ const normalized = tag.toLowerCase();
1279
+ tags[normalized] = (tags[normalized] || 0) + 1;
1280
+ });
1281
+ });
1282
+ return tags;
1283
+ }
1284
+
1285
+ export function getNotesByTag(tag: string): NoteData[] {
1286
+ return getAllNotes().filter(n =>
1287
+ n.tags.map(t => t.toLowerCase()).includes(tag.toLowerCase())
1288
+ );
1289
+ }
1290
+
1291
+ // ─── Slug Registry ───────────────────────────────────────────────────────────
1292
+
1293
+ export interface SlugRegistryEntry {
1294
+ url: string;
1295
+ type: 'post' | 'note' | 'flow' | 'series';
1296
+ title: string;
1297
+ }
1298
+
1299
+ let _slugRegistry: Map<string, SlugRegistryEntry> | null = null;
1300
+
1301
+ export function buildSlugRegistry(): Map<string, SlugRegistryEntry> {
1302
+ if (_slugRegistry && process.env.NODE_ENV === 'production') return _slugRegistry;
1303
+
1304
+ const map = new Map<string, SlugRegistryEntry>();
1305
+
1306
+ getAllPosts().forEach(p =>
1307
+ map.set(p.slug, { url: `/posts/${p.slug}`, type: 'post', title: p.title })
1308
+ );
1309
+
1310
+ getAllFlows().forEach(f =>
1311
+ map.set(f.slug, { url: `/flows/${f.slug}`, type: 'flow', title: f.title })
1312
+ );
1313
+
1314
+ getAllNotes().forEach(n => {
1315
+ if (map.has(n.slug)) {
1316
+ console.warn(`[slugRegistry] Note slug "${n.slug}" conflicts with an existing entry.`);
1317
+ }
1318
+ map.set(n.slug, { url: `/notes/${n.slug}`, type: 'note', title: n.title });
1319
+ n.aliases.forEach(a => {
1320
+ if (map.has(a)) {
1321
+ console.warn(`[slugRegistry] Note alias "${a}" (→ ${n.slug}) conflicts with existing slug; skipping.`);
1322
+ } else {
1323
+ map.set(a, { url: `/notes/${n.slug}`, type: 'note', title: n.title });
1324
+ }
1325
+ });
1326
+ });
1327
+
1328
+ if (fs.existsSync(seriesDirectory)) {
1329
+ fs.readdirSync(seriesDirectory, { withFileTypes: true }).forEach(entry => {
1330
+ if (!entry.isDirectory()) return;
1331
+ const slug = entry.name;
1332
+ const seriesData = getSeriesData(slug);
1333
+ map.set(slug, {
1334
+ url: `/series/${slug}`,
1335
+ type: 'series',
1336
+ title: seriesData?.title || slug,
1337
+ });
1338
+ });
1339
+ }
1340
+
1341
+ _slugRegistry = map;
1342
+ return map;
1343
+ }
1344
+
1345
+ // ─── Backlink Index ──────────────────────────────────────────────────────────
1346
+
1347
+ export interface BacklinkSource {
1348
+ slug: string;
1349
+ title: string;
1350
+ type: 'post' | 'note' | 'flow' | 'series';
1351
+ url: string;
1352
+ context: string;
1353
+ }
1354
+
1355
+ function extractWikilinkContext(text: string, matchStart: number, matchEnd: number): string {
1356
+ const RADIUS = 120;
1357
+ const start = Math.max(0, matchStart - RADIUS);
1358
+ const end = Math.min(text.length, matchEnd + RADIUS);
1359
+ let ctx = text.slice(start, end);
1360
+
1361
+ // Replace wikilinks in context with just display text for readability
1362
+ ctx = ctx.replace(/\[\[([^\]|]+?)(?:\|([^\]]+?))?\]\]/g, (_, slug, display) => display || slug);
1363
+
1364
+ if (start > 0) ctx = ctx.replace(/^[^\s.!?]{1,30}/, '').trimStart();
1365
+ if (end < text.length) ctx = ctx.replace(/[^\s.!?]{1,30}$/, '').trimEnd();
1366
+
1367
+ return ctx.trim().slice(0, 200);
1368
+ }
1369
+
1370
+ function buildBacklinkIndex(): Map<string, BacklinkSource[]> {
1371
+ const index = new Map<string, BacklinkSource[]>();
1372
+
1373
+ const addBacklinks = (
1374
+ content: string,
1375
+ sourceSlug: string,
1376
+ sourceTitle: string,
1377
+ sourceType: BacklinkSource['type'],
1378
+ sourceUrl: string
1379
+ ) => {
1380
+ // Create a fresh RegExp per call to avoid lastIndex issues with 'g' flag
1381
+ const WIKILINK = /\[\[([^\]|]+?)(?:\|([^\]]+?))?\]\]/g;
1382
+ let match;
1383
+ while ((match = WIKILINK.exec(content)) !== null) {
1384
+ const targetSlug = match[1].trim();
1385
+ if (targetSlug === sourceSlug) continue; // skip self-references
1386
+ const context = extractWikilinkContext(content, match.index, match.index + match[0].length);
1387
+ let sources = index.get(targetSlug);
1388
+ if (!sources) {
1389
+ sources = [];
1390
+ index.set(targetSlug, sources);
1391
+ }
1392
+ if (!sources.some(b => b.slug === sourceSlug && b.type === sourceType)) {
1393
+ sources.push({ slug: sourceSlug, title: sourceTitle, type: sourceType, url: sourceUrl, context });
1394
+ }
1395
+ }
1396
+ };
1397
+
1398
+ getAllPosts().forEach(p => addBacklinks(p.content, p.slug, p.title, 'post', `/posts/${p.slug}`));
1399
+ getAllNotes().forEach(n => addBacklinks(n.content, n.slug, n.title, 'note', `/notes/${n.slug}`));
1400
+ getAllFlows().forEach(f => addBacklinks(f.content, f.slug, f.title, 'flow', `/flows/${f.slug}`));
1401
+
1402
+ return index;
1403
+ }
1404
+
1405
+ let _backlinkIndex: Map<string, BacklinkSource[]> | null = null;
1406
+
1407
+ export function getBacklinks(slug: string): BacklinkSource[] {
1408
+ if (!_backlinkIndex || process.env.NODE_ENV !== 'production') _backlinkIndex = buildBacklinkIndex();
1409
+ return _backlinkIndex.get(slug) ?? [];
1410
+ }
@@ -0,0 +1,59 @@
1
+ import { visit } from 'unist-util-visit';
2
+ import type { Root, Text, Parent } from 'mdast';
3
+ import type { SlugRegistryEntry } from './markdown';
4
+
5
+ interface WikilinksOptions {
6
+ slugRegistry: Map<string, SlugRegistryEntry>;
7
+ }
8
+
9
+ export default function remarkWikilinks({ slugRegistry }: WikilinksOptions) {
10
+ return (tree: Root) => {
11
+ visit(tree, 'text', (node: Text, index: number | undefined, parent: Parent | undefined) => {
12
+ if (!parent || index === undefined) return;
13
+ if (!node.value.includes('[[')) return;
14
+
15
+ // Create fresh regex each time to avoid lastIndex issue with 'g' flag
16
+ const WIKILINK = /\[\[([^\]|]+?)(?:\|([^\]]+?))?\]\]/g;
17
+ if (!WIKILINK.test(node.value)) return;
18
+ WIKILINK.lastIndex = 0;
19
+
20
+ const newNodes: (Text | { type: 'html'; value: string })[] = [];
21
+ let last = 0;
22
+ let match: RegExpExecArray | null;
23
+
24
+ while ((match = WIKILINK.exec(node.value)) !== null) {
25
+ if (match.index > last) {
26
+ newNodes.push({ type: 'text', value: node.value.slice(last, match.index) });
27
+ }
28
+
29
+ const slug = match[1].trim();
30
+ const display = match[2]?.trim() || slug;
31
+ const entry = slugRegistry.get(slug);
32
+
33
+ if (entry) {
34
+ newNodes.push({
35
+ type: 'html',
36
+ value: `<a href="${entry.url}" class="wikilink wikilink--resolved wikilink--${entry.type}">${display}</a>`,
37
+ });
38
+ } else {
39
+ newNodes.push({
40
+ type: 'html',
41
+ value: `<span class="wikilink wikilink--broken" title="[[${slug}]] not found">${display}</span>`,
42
+ });
43
+ }
44
+
45
+ last = match.index + match[0].length;
46
+ }
47
+
48
+ if (last < node.value.length) {
49
+ newNodes.push({ type: 'text', value: node.value.slice(last) });
50
+ }
51
+
52
+ if (newNodes.length > 1 || (newNodes.length === 1 && newNodes[0].type !== 'text')) {
53
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
54
+ (parent.children as any[]).splice(index, 1, ...newNodes);
55
+ return index + newNodes.length; // skip inserted nodes
56
+ }
57
+ });
58
+ };
59
+ }
@@ -1,9 +1,10 @@
1
- export type ContentType = 'All' | 'Post' | 'Flow' | 'Book';
1
+ export type ContentType = 'All' | 'Post' | 'Flow' | 'Book' | 'Note';
2
2
 
3
3
  /** Derive content type from a Pagefind result URL. */
4
4
  export function getResultType(url: string): Exclude<ContentType, 'All'> {
5
5
  if (url.includes('/flows/')) return 'Flow';
6
6
  if (url.includes('/books/')) return 'Book';
7
+ if (url.includes('/notes/')) return 'Note';
7
8
  return 'Post';
8
9
  }
9
10