@hutusi/amytis 1.7.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 +6 -0
- package/README.md +14 -0
- package/TODO.md +15 -3
- package/bun.lock +5 -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/docs/ARCHITECTURE.md +8 -0
- package/docs/CONTRIBUTING.md +11 -0
- package/docs/DIGITAL_GARDEN.md +64 -0
- package/package.json +7 -3
- 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 +21 -1
- package/src/app/flows/[year]/[month]/[day]/page.tsx +32 -29
- 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/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 +0 -1
- package/src/app/posts/[slug]/page.tsx +4 -2
- package/src/app/search.json/route.ts +15 -1
- package/src/components/Backlinks.tsx +39 -0
- 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/MarkdownRenderer.tsx +13 -2
- package/src/components/Navbar.tsx +235 -9
- 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 +5 -1
- package/src/components/TagContentTabs.tsx +0 -1
- package/src/i18n/translations.ts +21 -1
- package/src/layouts/PostLayout.tsx +8 -3
- package/src/lib/markdown.ts +276 -3
- package/src/lib/remark-wikilinks.ts +59 -0
- package/src/lib/search-utils.ts +2 -1
package/src/i18n/translations.ts
CHANGED
|
@@ -60,7 +60,7 @@ export const translations = {
|
|
|
60
60
|
selected_books: "Selected Books",
|
|
61
61
|
flow: "Flow",
|
|
62
62
|
recent_notes: "Recent Notes",
|
|
63
|
-
all_flows: "All
|
|
63
|
+
all_flows: "All Flows",
|
|
64
64
|
no_flows: "No notes yet.",
|
|
65
65
|
flow_subtitle: "{count} daily notes.",
|
|
66
66
|
flows_in_year: "Notes in {year}",
|
|
@@ -117,6 +117,16 @@ export const translations = {
|
|
|
117
117
|
sort_az: "A–Z",
|
|
118
118
|
tags_count: "{shown} / {total} tags",
|
|
119
119
|
tags_no_match: "No tags match \"{filter}\"",
|
|
120
|
+
notes: "Notes",
|
|
121
|
+
notes_subtitle: "{count} knowledge base notes.",
|
|
122
|
+
tab_daily_flow: "Daily",
|
|
123
|
+
tab_graph: "Graph",
|
|
124
|
+
backlinks: "Backlinks",
|
|
125
|
+
graph_subtitle: "A visual map of connected knowledge.",
|
|
126
|
+
search_type_note: "Note",
|
|
127
|
+
all_notes: "All Notes",
|
|
128
|
+
no_notes: "No notes yet.",
|
|
129
|
+
more: "More",
|
|
120
130
|
},
|
|
121
131
|
zh: {
|
|
122
132
|
home: "首页",
|
|
@@ -236,6 +246,16 @@ export const translations = {
|
|
|
236
246
|
sort_az: "A–Z",
|
|
237
247
|
tags_count: "{shown} / {total} 个标签",
|
|
238
248
|
tags_no_match: "未找到匹配\"{filter}\"的标签",
|
|
249
|
+
notes: "笔记",
|
|
250
|
+
notes_subtitle: "共 {count} 条知识库笔记。",
|
|
251
|
+
tab_daily_flow: "随笔",
|
|
252
|
+
tab_graph: "图谱",
|
|
253
|
+
backlinks: "反向链接",
|
|
254
|
+
graph_subtitle: "知识连接的可视化地图。",
|
|
255
|
+
search_type_note: "笔记",
|
|
256
|
+
all_notes: "全部笔记",
|
|
257
|
+
no_notes: "暂无笔记。",
|
|
258
|
+
more: "更多",
|
|
239
259
|
},
|
|
240
260
|
};
|
|
241
261
|
|
|
@@ -1,11 +1,12 @@
|
|
|
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';
|
|
11
12
|
import PostNavigation from '@/components/PostNavigation';
|
|
@@ -21,9 +22,11 @@ interface PostLayoutProps {
|
|
|
21
22
|
seriesTitle?: string;
|
|
22
23
|
prevPost?: PostData | null;
|
|
23
24
|
nextPost?: PostData | null;
|
|
25
|
+
backlinks?: BacklinkSource[];
|
|
26
|
+
slugRegistry?: Map<string, SlugRegistryEntry>;
|
|
24
27
|
}
|
|
25
28
|
|
|
26
|
-
export default function PostLayout({ post, relatedPosts, seriesPosts, seriesTitle, prevPost, nextPost }: PostLayoutProps) {
|
|
29
|
+
export default function PostLayout({ post, relatedPosts, seriesPosts, seriesTitle, prevPost, nextPost, backlinks, slugRegistry }: PostLayoutProps) {
|
|
27
30
|
const showToc = siteConfig.posts?.toc !== false && post.toc !== false && post.headings && post.headings.length > 0;
|
|
28
31
|
const hasSeries = !!(post.series && seriesPosts && seriesPosts.length > 0);
|
|
29
32
|
const showSidebar = showToc || hasSeries;
|
|
@@ -110,7 +113,7 @@ export default function PostLayout({ post, relatedPosts, seriesPosts, seriesTitl
|
|
|
110
113
|
</div>
|
|
111
114
|
)}
|
|
112
115
|
|
|
113
|
-
<MarkdownRenderer content={post.content} latex={post.latex} slug={post.slug} />
|
|
116
|
+
<MarkdownRenderer content={post.content} latex={post.latex} slug={post.slug} slugRegistry={slugRegistry} />
|
|
114
117
|
|
|
115
118
|
{post.tags && post.tags.length > 0 && (
|
|
116
119
|
<div className="mt-12 pt-12 border-t border-muted/20 flex flex-wrap items-center gap-2">
|
|
@@ -125,6 +128,8 @@ export default function PostLayout({ post, relatedPosts, seriesPosts, seriesTitl
|
|
|
125
128
|
<ExternalLinks links={post.externalLinks} />
|
|
126
129
|
)}
|
|
127
130
|
|
|
131
|
+
<Backlinks backlinks={backlinks ?? []} />
|
|
132
|
+
|
|
128
133
|
<ShareBar
|
|
129
134
|
url={postUrl}
|
|
130
135
|
title={post.title}
|
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
|
|
|
@@ -965,7 +974,7 @@ export function getBooksByAuthor(author: string): BookData[] {
|
|
|
965
974
|
// ─── Flows (Daily Notes) ────────────────────────────────────────────────────
|
|
966
975
|
|
|
967
976
|
const FlowSchema = z.object({
|
|
968
|
-
title: z.string(),
|
|
977
|
+
title: z.string().optional(),
|
|
969
978
|
date: z.union([z.string(), z.date()]).transform(val => new Date(val).toISOString().split('T')[0]).optional(),
|
|
970
979
|
tags: z.array(z.string()).optional().default([]),
|
|
971
980
|
draft: z.boolean().optional().default(false),
|
|
@@ -1001,7 +1010,7 @@ function parseFlowFile(fullPath: string, slug: string): FlowData {
|
|
|
1001
1010
|
return {
|
|
1002
1011
|
slug,
|
|
1003
1012
|
date,
|
|
1004
|
-
title: data.title,
|
|
1013
|
+
title: data.title ?? date, // fall back to date string if no title in frontmatter
|
|
1005
1014
|
tags: data.tags,
|
|
1006
1015
|
draft: data.draft,
|
|
1007
1016
|
content: contentWithoutH1,
|
|
@@ -1121,3 +1130,267 @@ export function getAdjacentFlows(slug: string): { prev: FlowData | null; next: F
|
|
|
1121
1130
|
export function getRecentFlows(limit: number = 5): FlowData[] {
|
|
1122
1131
|
return getAllFlows().slice(0, limit);
|
|
1123
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
|
+
}
|
|
@@ -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
|
|