@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.
Files changed (54) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/GEMINI.md +6 -0
  3. package/README.md +14 -0
  4. package/TODO.md +15 -3
  5. package/bun.lock +5 -3
  6. package/content/flows/2026/02/05.md +0 -1
  7. package/content/flows/2026/02/10.mdx +2 -1
  8. package/content/flows/2026/02/15.md +2 -1
  9. package/content/flows/2026/02/18.mdx +2 -1
  10. package/content/flows/2026/02/20.md +0 -1
  11. package/content/notes/algorithms-and-data-structures.mdx +51 -0
  12. package/content/notes/digital-garden-philosophy.mdx +36 -0
  13. package/content/notes/react-server-components.mdx +49 -0
  14. package/content/notes/tailwind-v4.mdx +45 -0
  15. package/content/notes/zettelkasten-method.mdx +33 -0
  16. package/docs/ARCHITECTURE.md +8 -0
  17. package/docs/CONTRIBUTING.md +11 -0
  18. package/docs/DIGITAL_GARDEN.md +64 -0
  19. package/package.json +7 -3
  20. package/scripts/generate-knowledge-graph.ts +162 -0
  21. package/scripts/new-flow.ts +0 -5
  22. package/scripts/new-note.ts +53 -0
  23. package/site.config.ts +21 -1
  24. package/src/app/flows/[year]/[month]/[day]/page.tsx +32 -29
  25. package/src/app/flows/[year]/[month]/page.tsx +15 -13
  26. package/src/app/flows/[year]/page.tsx +22 -15
  27. package/src/app/flows/page/[page]/page.tsx +3 -9
  28. package/src/app/flows/page.tsx +3 -8
  29. package/src/app/globals.css +41 -0
  30. package/src/app/graph/page.tsx +19 -0
  31. package/src/app/notes/[slug]/page.tsx +128 -0
  32. package/src/app/notes/page/[page]/page.tsx +58 -0
  33. package/src/app/notes/page.tsx +31 -0
  34. package/src/app/page.tsx +0 -1
  35. package/src/app/posts/[slug]/page.tsx +4 -2
  36. package/src/app/search.json/route.ts +15 -1
  37. package/src/components/Backlinks.tsx +39 -0
  38. package/src/components/FlowCalendarSidebar.tsx +4 -2
  39. package/src/components/FlowContent.tsx +4 -3
  40. package/src/components/FlowHubTabs.tsx +50 -0
  41. package/src/components/FlowTimelineEntry.tsx +7 -9
  42. package/src/components/KnowledgeGraph.tsx +324 -0
  43. package/src/components/MarkdownRenderer.tsx +13 -2
  44. package/src/components/Navbar.tsx +235 -9
  45. package/src/components/NoteContent.tsx +123 -0
  46. package/src/components/NoteSidebar.tsx +132 -0
  47. package/src/components/RecentNotesSection.tsx +6 -11
  48. package/src/components/Search.tsx +5 -1
  49. package/src/components/TagContentTabs.tsx +0 -1
  50. package/src/i18n/translations.ts +21 -1
  51. package/src/layouts/PostLayout.tsx +8 -3
  52. package/src/lib/markdown.ts +276 -3
  53. package/src/lib/remark-wikilinks.ts +59 -0
  54. 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