@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.
- package/.github/workflows/ci.yml +1 -1
- package/CHANGELOG.md +63 -0
- package/CLAUDE.md +9 -18
- package/GEMINI.md +6 -0
- package/README.md +44 -0
- package/TODO.md +15 -3
- package/bun.lock +5 -3
- package/content/about.mdx +64 -10
- package/content/about.zh.mdx +66 -9
- package/content/books/sample-book/index.mdx +3 -3
- 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 +0 -1
- 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/series/digital-garden/01-philosophy.mdx +25 -12
- package/docs/ARCHITECTURE.md +9 -1
- package/docs/CONTRIBUTING.md +26 -0
- package/docs/DIGITAL_GARDEN.md +72 -0
- package/imports/README.md +45 -0
- package/package.json +12 -5
- package/scripts/generate-knowledge-graph.ts +162 -0
- package/scripts/import-book.ts +176 -0
- package/scripts/new-flow-from-chat.ts +238 -0
- package/scripts/new-flow.ts +0 -5
- package/scripts/new-note.ts +53 -0
- package/scripts/sync-book-chapters.ts +210 -0
- package/site.config.ts +30 -7
- package/src/app/authors/[author]/page.tsx +3 -1
- package/src/app/books/[slug]/[chapter]/page.tsx +2 -1
- package/src/app/books/[slug]/page.tsx +6 -5
- package/src/app/flows/[year]/[month]/[day]/page.tsx +35 -29
- package/src/app/flows/[year]/[month]/page.tsx +18 -13
- package/src/app/flows/[year]/page.tsx +25 -15
- package/src/app/flows/page/[page]/page.tsx +5 -9
- package/src/app/flows/page.tsx +5 -8
- package/src/app/globals.css +41 -0
- package/src/app/graph/page.tsx +21 -0
- package/src/app/layout.tsx +4 -2
- package/src/app/notes/[slug]/page.tsx +129 -0
- package/src/app/notes/page/[page]/page.tsx +60 -0
- package/src/app/notes/page.tsx +33 -0
- package/src/app/page/[page]/page.tsx +1 -0
- package/src/app/page.tsx +4 -5
- package/src/app/posts/[slug]/page.tsx +5 -2
- package/src/app/posts/page/[page]/page.tsx +4 -1
- package/src/app/search.json/route.ts +17 -3
- package/src/app/series/[slug]/page/[page]/page.tsx +1 -0
- package/src/app/series/[slug]/page.tsx +3 -3
- package/src/app/sitemap.ts +1 -1
- package/src/app/tags/[tag]/page.tsx +3 -3
- package/src/components/Backlinks.tsx +39 -0
- package/src/components/BookMobileNav.tsx +11 -11
- package/src/components/BookSidebar.tsx +17 -25
- package/src/components/BrowserDetectionBanner.tsx +96 -0
- package/src/components/FeaturedStoriesSection.tsx +1 -1
- 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/KnowledgeGraph.tsx +324 -0
- package/src/components/LanguageProvider.tsx +14 -5
- package/src/components/MarkdownRenderer.tsx +13 -2
- package/src/components/Navbar.tsx +237 -10
- package/src/components/NoteContent.tsx +123 -0
- package/src/components/NoteSidebar.tsx +132 -0
- package/src/components/RecentNotesSection.tsx +6 -11
- package/src/components/Search.tsx +7 -3
- package/src/components/TagContentTabs.tsx +0 -1
- package/src/i18n/translations.ts +43 -17
- package/src/layouts/BookLayout.tsx +3 -3
- package/src/layouts/PostLayout.tsx +8 -3
- package/src/lib/i18n.ts +83 -6
- package/src/lib/markdown.ts +306 -19
- package/src/lib/remark-wikilinks.ts +59 -0
- package/src/lib/search-utils.ts +2 -1
- package/tests/unit/static-params.test.ts +238 -0
- package/content/series/digital-garden/01-philosophy/index.mdx +0 -23
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(),
|
|
@@ -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(/`[^`]
|
|
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
|
-
|
|
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
|
-
|
|
762
|
+
id: string;
|
|
749
763
|
part?: string;
|
|
750
764
|
}
|
|
751
765
|
|
|
752
766
|
export interface BookTocPart {
|
|
753
767
|
part: string;
|
|
754
|
-
chapters: { title: string;
|
|
768
|
+
chapters: { title: string; id: string }[];
|
|
755
769
|
}
|
|
756
|
-
export type BookTocItem = BookTocPart | { title: 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
|
-
|
|
782
|
-
|
|
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
|
-
|
|
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,
|
|
836
|
+
result.push({ title: ch.title, id: ch.id, part: item.part });
|
|
822
837
|
}
|
|
823
838
|
} else {
|
|
824
|
-
result.push({ title: item.title,
|
|
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.
|
|
856
|
-
const chMd = path.join(bookDir, `${ch.
|
|
857
|
-
|
|
858
|
-
|
|
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.
|
|
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
|
-
|
|
934
|
-
|
|
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
|
+
}
|
package/src/lib/search-utils.ts
CHANGED
|
@@ -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
|
|