@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
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, useState, useSyncExternalStore } from 'react';
|
|
4
|
+
import { useRouter } from 'next/navigation';
|
|
5
|
+
import Link from 'next/link';
|
|
6
|
+
import * as d3 from 'd3';
|
|
7
|
+
|
|
8
|
+
// Reactive mobile detection via useSyncExternalStore (avoids setState-in-effect lint rule)
|
|
9
|
+
function subscribeToResize(callback: () => void) {
|
|
10
|
+
window.addEventListener('resize', callback);
|
|
11
|
+
return () => window.removeEventListener('resize', callback);
|
|
12
|
+
}
|
|
13
|
+
function getIsMobile() { return window.innerWidth < 768; }
|
|
14
|
+
function getServerIsMobile() { return false; }
|
|
15
|
+
|
|
16
|
+
interface GraphNode extends d3.SimulationNodeDatum {
|
|
17
|
+
id: string;
|
|
18
|
+
title: string;
|
|
19
|
+
type: 'post' | 'note' | 'flow' | 'series';
|
|
20
|
+
url: string;
|
|
21
|
+
connections: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface GraphEdge {
|
|
25
|
+
source: string | GraphNode;
|
|
26
|
+
target: string | GraphNode;
|
|
27
|
+
type: 'wikilink' | 'series';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface GraphData {
|
|
31
|
+
nodes: GraphNode[];
|
|
32
|
+
edges: GraphEdge[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const NODE_COLORS: Record<string, string> = {
|
|
36
|
+
note: 'var(--accent)',
|
|
37
|
+
post: '#2563eb',
|
|
38
|
+
flow: '#f59e0b',
|
|
39
|
+
series: '#10b981',
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const TYPE_FILTERS = ['note', 'post', 'flow', 'series'] as const;
|
|
43
|
+
|
|
44
|
+
function nodeRadius(connections: number): number {
|
|
45
|
+
return Math.max(5, Math.min(20, 5 + connections * 1.5));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export default function KnowledgeGraph() {
|
|
49
|
+
const svgRef = useRef<SVGSVGElement>(null);
|
|
50
|
+
const router = useRouter();
|
|
51
|
+
const routerRef = useRef(router);
|
|
52
|
+
useEffect(() => { routerRef.current = router; });
|
|
53
|
+
const [loading, setLoading] = useState(true);
|
|
54
|
+
const [error, setError] = useState<string | null>(null);
|
|
55
|
+
const [activeTypes, setActiveTypes] = useState<Set<string>>(new Set(TYPE_FILTERS));
|
|
56
|
+
const isMobile = useSyncExternalStore(subscribeToResize, getIsMobile, getServerIsMobile);
|
|
57
|
+
const [graphData, setGraphData] = useState<GraphData | null>(null);
|
|
58
|
+
const [searchQuery, setSearchQuery] = useState('');
|
|
59
|
+
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
fetch('/knowledge-graph.json')
|
|
62
|
+
.then(res => {
|
|
63
|
+
if (!res.ok) throw new Error('Graph data not found. Run `bun run build:graph` to generate it.');
|
|
64
|
+
return res.json();
|
|
65
|
+
})
|
|
66
|
+
.then((data: GraphData) => {
|
|
67
|
+
setGraphData(data);
|
|
68
|
+
setLoading(false);
|
|
69
|
+
})
|
|
70
|
+
.catch((err: Error) => {
|
|
71
|
+
setError(err.message);
|
|
72
|
+
setLoading(false);
|
|
73
|
+
});
|
|
74
|
+
}, []);
|
|
75
|
+
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
if (!graphData || !svgRef.current || isMobile) return;
|
|
78
|
+
|
|
79
|
+
const svg = svgRef.current;
|
|
80
|
+
const width = svg.clientWidth || 800;
|
|
81
|
+
const height = svg.clientHeight || 600;
|
|
82
|
+
|
|
83
|
+
// Clear previous content
|
|
84
|
+
d3.select(svg).selectAll('*').remove();
|
|
85
|
+
|
|
86
|
+
// Deep-copy nodes so simulation can modify them
|
|
87
|
+
const filteredNodes: GraphNode[] = graphData.nodes
|
|
88
|
+
.filter(n => activeTypes.has(n.type))
|
|
89
|
+
.map(n => ({ ...n }));
|
|
90
|
+
|
|
91
|
+
const filteredNodeIds = new Set(filteredNodes.map(n => n.id));
|
|
92
|
+
const filteredEdges: GraphEdge[] = graphData.edges
|
|
93
|
+
.filter(e => {
|
|
94
|
+
const src = typeof e.source === 'string' ? e.source : e.source.id;
|
|
95
|
+
const tgt = typeof e.target === 'string' ? e.target : e.target.id;
|
|
96
|
+
return filteredNodeIds.has(src) && filteredNodeIds.has(tgt);
|
|
97
|
+
})
|
|
98
|
+
.map(e => ({ ...e }));
|
|
99
|
+
|
|
100
|
+
const svgEl = d3.select(svg);
|
|
101
|
+
|
|
102
|
+
// Tooltip
|
|
103
|
+
const tooltip = d3.select('body')
|
|
104
|
+
.append('div')
|
|
105
|
+
.style('position', 'fixed')
|
|
106
|
+
.style('pointer-events', 'none')
|
|
107
|
+
.style('background', 'var(--background)')
|
|
108
|
+
.style('border', '1px solid color-mix(in srgb, var(--muted) 20%, transparent)')
|
|
109
|
+
.style('border-radius', '6px')
|
|
110
|
+
.style('padding', '6px 10px')
|
|
111
|
+
.style('font-size', '12px')
|
|
112
|
+
.style('color', 'var(--foreground)')
|
|
113
|
+
.style('opacity', '0')
|
|
114
|
+
.style('z-index', '100')
|
|
115
|
+
.style('max-width', '200px')
|
|
116
|
+
.style('box-shadow', '0 4px 6px rgba(0,0,0,0.1)');
|
|
117
|
+
|
|
118
|
+
// Container group for zoom/pan
|
|
119
|
+
const g = svgEl.append('g');
|
|
120
|
+
|
|
121
|
+
// Zoom behavior
|
|
122
|
+
const zoom = d3.zoom<SVGSVGElement, unknown>()
|
|
123
|
+
.scaleExtent([0.2, 4])
|
|
124
|
+
.on('zoom', (event) => {
|
|
125
|
+
g.attr('transform', event.transform.toString());
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
svgEl.call(zoom);
|
|
129
|
+
|
|
130
|
+
// Simulation
|
|
131
|
+
const simulation = d3.forceSimulation<GraphNode>(filteredNodes)
|
|
132
|
+
.force('link',
|
|
133
|
+
d3.forceLink<GraphNode, GraphEdge>(filteredEdges)
|
|
134
|
+
.id(d => d.id)
|
|
135
|
+
.distance(80)
|
|
136
|
+
)
|
|
137
|
+
.force('charge', d3.forceManyBody<GraphNode>().strength(-150))
|
|
138
|
+
.force('center', d3.forceCenter(width / 2, height / 2))
|
|
139
|
+
.force('collide', d3.forceCollide<GraphNode>().radius(d => nodeRadius(d.connections) + 2));
|
|
140
|
+
|
|
141
|
+
// Edges
|
|
142
|
+
const link = g.append('g')
|
|
143
|
+
.selectAll<SVGLineElement, GraphEdge>('line')
|
|
144
|
+
.data(filteredEdges)
|
|
145
|
+
.join('line')
|
|
146
|
+
.attr('stroke', 'currentColor')
|
|
147
|
+
.attr('stroke-opacity', 0.2)
|
|
148
|
+
.attr('stroke-width', 1);
|
|
149
|
+
|
|
150
|
+
// Node groups
|
|
151
|
+
const node = g.append('g')
|
|
152
|
+
.selectAll<SVGGElement, GraphNode>('g')
|
|
153
|
+
.data(filteredNodes)
|
|
154
|
+
.join('g')
|
|
155
|
+
.style('cursor', 'pointer')
|
|
156
|
+
.call(
|
|
157
|
+
d3.drag<SVGGElement, GraphNode>()
|
|
158
|
+
.on('start', (event, d) => {
|
|
159
|
+
if (!event.active) simulation.alphaTarget(0.3).restart();
|
|
160
|
+
d.fx = d.x;
|
|
161
|
+
d.fy = d.y;
|
|
162
|
+
})
|
|
163
|
+
.on('drag', (event, d) => {
|
|
164
|
+
d.fx = event.x;
|
|
165
|
+
d.fy = event.y;
|
|
166
|
+
})
|
|
167
|
+
.on('end', (event, d) => {
|
|
168
|
+
if (!event.active) simulation.alphaTarget(0);
|
|
169
|
+
d.fx = null;
|
|
170
|
+
d.fy = null;
|
|
171
|
+
})
|
|
172
|
+
)
|
|
173
|
+
.on('click', (_, d) => {
|
|
174
|
+
routerRef.current.push(d.url);
|
|
175
|
+
})
|
|
176
|
+
.on('mouseenter', (event: MouseEvent, d) => {
|
|
177
|
+
tooltip.style('opacity', '1').text('');
|
|
178
|
+
tooltip.append('span').style('font-weight', 'bold').text(d.title);
|
|
179
|
+
tooltip.append('br');
|
|
180
|
+
tooltip.append('span').style('opacity', '0.6').text(`${d.type} · ${d.connections} links`);
|
|
181
|
+
})
|
|
182
|
+
.on('mousemove', (event: MouseEvent) => {
|
|
183
|
+
tooltip
|
|
184
|
+
.style('left', `${event.clientX + 12}px`)
|
|
185
|
+
.style('top', `${event.clientY - 20}px`);
|
|
186
|
+
})
|
|
187
|
+
.on('mouseleave', () => {
|
|
188
|
+
tooltip.style('opacity', '0');
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
node.append('circle')
|
|
192
|
+
.attr('r', d => nodeRadius(d.connections))
|
|
193
|
+
.attr('fill', d => NODE_COLORS[d.type] || '#888')
|
|
194
|
+
.attr('fill-opacity', 0.85)
|
|
195
|
+
.attr('stroke', '#fff')
|
|
196
|
+
.attr('stroke-opacity', 0.3)
|
|
197
|
+
.attr('stroke-width', 1.5);
|
|
198
|
+
|
|
199
|
+
// Labels for well-connected nodes
|
|
200
|
+
node.filter(d => d.connections >= 3)
|
|
201
|
+
.append('text')
|
|
202
|
+
.text(d => d.title.length > 20 ? d.title.slice(0, 18) + '…' : d.title)
|
|
203
|
+
.attr('x', d => nodeRadius(d.connections) + 4)
|
|
204
|
+
.attr('y', 4)
|
|
205
|
+
.attr('font-size', '10px')
|
|
206
|
+
.attr('fill', 'currentColor')
|
|
207
|
+
.attr('opacity', 0.7)
|
|
208
|
+
.attr('pointer-events', 'none');
|
|
209
|
+
|
|
210
|
+
simulation.on('tick', () => {
|
|
211
|
+
link
|
|
212
|
+
.attr('x1', d => (d.source as GraphNode).x ?? 0)
|
|
213
|
+
.attr('y1', d => (d.source as GraphNode).y ?? 0)
|
|
214
|
+
.attr('x2', d => (d.target as GraphNode).x ?? 0)
|
|
215
|
+
.attr('y2', d => (d.target as GraphNode).y ?? 0);
|
|
216
|
+
|
|
217
|
+
node.attr('transform', d => `translate(${d.x ?? 0},${d.y ?? 0})`);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
return () => {
|
|
221
|
+
simulation.stop();
|
|
222
|
+
tooltip.remove();
|
|
223
|
+
};
|
|
224
|
+
}, [graphData, activeTypes, isMobile]);
|
|
225
|
+
|
|
226
|
+
function toggleType(type: string) {
|
|
227
|
+
setActiveTypes(prev => {
|
|
228
|
+
const next = new Set(prev);
|
|
229
|
+
if (next.has(type)) {
|
|
230
|
+
if (next.size > 1) next.delete(type);
|
|
231
|
+
} else {
|
|
232
|
+
next.add(type);
|
|
233
|
+
}
|
|
234
|
+
return next;
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (loading) {
|
|
239
|
+
return (
|
|
240
|
+
<div className="flex items-center justify-center h-64 text-muted text-sm">
|
|
241
|
+
Loading graph…
|
|
242
|
+
</div>
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (error) {
|
|
247
|
+
return (
|
|
248
|
+
<div className="rounded-lg border border-muted/20 bg-muted/5 p-8 text-center text-sm text-muted">
|
|
249
|
+
<p className="mb-2">{error}</p>
|
|
250
|
+
<code className="text-xs bg-muted/10 px-1.5 py-0.5 rounded">bun run build:graph</code>
|
|
251
|
+
</div>
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Mobile: render searchable list
|
|
256
|
+
if (isMobile && graphData) {
|
|
257
|
+
const filtered = graphData.nodes
|
|
258
|
+
.filter(n => activeTypes.has(n.type))
|
|
259
|
+
.filter(n => !searchQuery || n.title.toLowerCase().includes(searchQuery.toLowerCase()));
|
|
260
|
+
|
|
261
|
+
return (
|
|
262
|
+
<div className="space-y-4">
|
|
263
|
+
<input
|
|
264
|
+
type="text"
|
|
265
|
+
placeholder="Search nodes…"
|
|
266
|
+
value={searchQuery}
|
|
267
|
+
onChange={e => setSearchQuery(e.target.value)}
|
|
268
|
+
className="w-full px-3 py-2 text-sm border border-muted/20 rounded-lg bg-transparent outline-none focus:border-accent"
|
|
269
|
+
/>
|
|
270
|
+
<div className="space-y-1">
|
|
271
|
+
{filtered.map(n => (
|
|
272
|
+
<Link
|
|
273
|
+
key={n.id}
|
|
274
|
+
href={n.url}
|
|
275
|
+
className="flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-muted/5 no-underline text-sm text-foreground"
|
|
276
|
+
>
|
|
277
|
+
<span
|
|
278
|
+
className="w-2 h-2 rounded-full shrink-0"
|
|
279
|
+
style={{ background: NODE_COLORS[n.type] }}
|
|
280
|
+
/>
|
|
281
|
+
<span className="flex-1 truncate">{n.title}</span>
|
|
282
|
+
<span className="text-xs text-muted">{n.type}</span>
|
|
283
|
+
</Link>
|
|
284
|
+
))}
|
|
285
|
+
</div>
|
|
286
|
+
</div>
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return (
|
|
291
|
+
<div className="space-y-4">
|
|
292
|
+
{/* Filter strip */}
|
|
293
|
+
<div className="flex items-center gap-2 flex-wrap">
|
|
294
|
+
{TYPE_FILTERS.map(type => (
|
|
295
|
+
<button
|
|
296
|
+
key={type}
|
|
297
|
+
onClick={() => toggleType(type)}
|
|
298
|
+
aria-pressed={activeTypes.has(type)}
|
|
299
|
+
className={`flex items-center gap-1.5 px-3 py-1 text-xs rounded-full border transition-colors ${
|
|
300
|
+
activeTypes.has(type)
|
|
301
|
+
? 'border-current text-foreground'
|
|
302
|
+
: 'border-muted/20 text-muted'
|
|
303
|
+
}`}
|
|
304
|
+
>
|
|
305
|
+
<span className="w-2 h-2 rounded-full" style={{ background: NODE_COLORS[type] }} />
|
|
306
|
+
{type}
|
|
307
|
+
</button>
|
|
308
|
+
))}
|
|
309
|
+
{graphData && (
|
|
310
|
+
<span className="text-xs text-muted ml-auto">
|
|
311
|
+
{graphData.nodes.filter(n => activeTypes.has(n.type)).length} nodes
|
|
312
|
+
</span>
|
|
313
|
+
)}
|
|
314
|
+
</div>
|
|
315
|
+
|
|
316
|
+
{/* Graph SVG */}
|
|
317
|
+
<svg
|
|
318
|
+
ref={svgRef}
|
|
319
|
+
className="w-full rounded-lg border border-muted/20 bg-muted/5"
|
|
320
|
+
style={{ height: '600px' }}
|
|
321
|
+
/>
|
|
322
|
+
</div>
|
|
323
|
+
);
|
|
324
|
+
}
|
|
@@ -7,19 +7,26 @@ import remarkMath from 'remark-math';
|
|
|
7
7
|
import rehypeKatex from 'rehype-katex';
|
|
8
8
|
import rehypeSlug from 'rehype-slug';
|
|
9
9
|
import rehypeImageMetadata from '@/lib/rehype-image-metadata';
|
|
10
|
+
import remarkWikilinks from '@/lib/remark-wikilinks';
|
|
10
11
|
import ExportedImage from 'next-image-export-optimizer';
|
|
11
12
|
import { PluggableList } from 'unified';
|
|
13
|
+
import type { SlugRegistryEntry } from '@/lib/markdown';
|
|
12
14
|
|
|
13
15
|
interface MarkdownRendererProps {
|
|
14
16
|
content: string;
|
|
15
17
|
latex?: boolean;
|
|
16
18
|
slug?: string;
|
|
19
|
+
slugRegistry?: Map<string, SlugRegistryEntry>;
|
|
17
20
|
}
|
|
18
21
|
|
|
19
|
-
export default function MarkdownRenderer({ content, latex = false, slug }: MarkdownRendererProps) {
|
|
22
|
+
export default function MarkdownRenderer({ content, latex = false, slug, slugRegistry }: MarkdownRendererProps) {
|
|
20
23
|
const remarkPlugins: PluggableList = [remarkGfm];
|
|
21
24
|
const rehypePlugins: PluggableList = [rehypeRaw, rehypeSlug, [rehypeImageMetadata, { slug }]];
|
|
22
25
|
|
|
26
|
+
if (slugRegistry && slugRegistry.size > 0) {
|
|
27
|
+
remarkPlugins.push([remarkWikilinks, { slugRegistry }]);
|
|
28
|
+
}
|
|
29
|
+
|
|
23
30
|
if (latex) {
|
|
24
31
|
remarkPlugins.push(remarkMath);
|
|
25
32
|
rehypePlugins.push(rehypeKatex);
|
|
@@ -58,7 +65,11 @@ export default function MarkdownRenderer({ content, latex = false, slug }: Markd
|
|
|
58
65
|
// Style links individually to avoid hover-all issue
|
|
59
66
|
a: (props) => {
|
|
60
67
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
61
|
-
const { node: _node, ...rest } = props as React.AnchorHTMLAttributes<HTMLAnchorElement> & ExtraProps;
|
|
68
|
+
const { node: _node, className, ...rest } = props as React.AnchorHTMLAttributes<HTMLAnchorElement> & ExtraProps;
|
|
69
|
+
// Preserve wikilink classes injected by remark-wikilinks — they have their own CSS styling
|
|
70
|
+
if (className?.includes('wikilink')) {
|
|
71
|
+
return <a {...rest} className={className} />;
|
|
72
|
+
}
|
|
62
73
|
return <a {...rest} className="text-accent no-underline hover:underline transition-colors duration-200" />;
|
|
63
74
|
},
|
|
64
75
|
// Custom code renderer: handles 'mermaid' blocks and syntax highlighting
|