@riebeckite/plugin-garden-explorer 0.0.1

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.
@@ -0,0 +1,452 @@
1
+ // components/garden-explorer.tsx
2
+ import { layoutRadialGraph } from "@riebeckite/core";
3
+ import { useEffect, useMemo, useState } from "hono/jsx";
4
+
5
+ // src/search-notes.ts
6
+ var FIELD_WEIGHTS = {
7
+ slug: 64,
8
+ title: 56,
9
+ tags: 44,
10
+ headings: 32,
11
+ body: 10
12
+ };
13
+ function searchGardenExplorerNotes(notes, query) {
14
+ const normalizedQuery = normalizeSearchQuery(query);
15
+ if (normalizedQuery.length === 0) return [];
16
+ return notes.map((note) => scoreNote(note, normalizedQuery)).filter(
17
+ (result) => result !== null
18
+ ).sort(
19
+ (a, b) => b.score - a.score || a.note.title.localeCompare(b.note.title, "ja")
20
+ ).map(({ note }) => note);
21
+ }
22
+ function normalizeSearchQuery(value) {
23
+ return normalizeSearchText(value).replace(/^#+/, "");
24
+ }
25
+ function normalizeSearchText(value) {
26
+ return value.toLocaleLowerCase().normalize("NFKC").replace(
27
+ /[ァ-ン]/g,
28
+ (char) => String.fromCharCode(char.charCodeAt(0) - 96)
29
+ );
30
+ }
31
+ function scoreNote(note, normalizedQuery) {
32
+ const matches = [
33
+ scoreField("title", note.title, normalizedQuery),
34
+ scoreField("slug", note.slug, normalizedQuery),
35
+ ...note.tags.map((tag) => scoreField("tags", tag, normalizedQuery)),
36
+ ...note.headings.map(
37
+ (heading) => scoreField("headings", heading, normalizedQuery)
38
+ ),
39
+ scoreField("body", note.body, normalizedQuery)
40
+ ].filter((match) => match.score > 0);
41
+ if (matches.length === 0) return null;
42
+ return {
43
+ note,
44
+ score: matches.reduce((total, match) => total + match.score, 0)
45
+ };
46
+ }
47
+ function scoreField(field, value, normalizedQuery) {
48
+ const normalizedValue = normalizeSearchText(value);
49
+ const index = normalizedValue.indexOf(normalizedQuery);
50
+ const weight = FIELD_WEIGHTS[field];
51
+ if (normalizedValue === normalizedQuery) return { score: weight * 3 };
52
+ if (index !== -1) return { score: weight * (index === 0 ? 2 : 1) };
53
+ const fuzzyScore = scoreFuzzyMatch(normalizedValue, normalizedQuery);
54
+ return { score: fuzzyScore > 0 ? Math.round(weight * fuzzyScore) : 0 };
55
+ }
56
+ function scoreFuzzyMatch(value, query) {
57
+ if (query.length < 2) return 0;
58
+ let valueIndex = 0;
59
+ let matched = 0;
60
+ let gaps = 0;
61
+ for (const queryCharacter of query) {
62
+ const nextIndex = value.indexOf(queryCharacter, valueIndex);
63
+ if (nextIndex === -1) return 0;
64
+ gaps += nextIndex - valueIndex;
65
+ valueIndex = nextIndex + 1;
66
+ matched += 1;
67
+ }
68
+ const coverage = matched / Math.max(value.length, query.length);
69
+ const continuityPenalty = Math.min(gaps / Math.max(value.length, 1), 0.8);
70
+ return Math.max(0, coverage * (1 - continuityPenalty));
71
+ }
72
+
73
+ // components/garden-explorer.tsx
74
+ import { jsx, jsxs } from "hono/jsx/jsx-runtime";
75
+ var GRAPH_WIDTH = 900;
76
+ var GRAPH_HEIGHT = 620;
77
+ var MIN_SCALE = 0.6;
78
+ var MAX_SCALE = 2.4;
79
+ function GardenExplorer(props) {
80
+ const noteBySlug = useMemo(
81
+ () => new Map(props.data.notes.map((note) => [note.slug, note])),
82
+ [props.data.notes]
83
+ );
84
+ const [query, setQuery] = useState("");
85
+ const [selectedTag, setSelectedTag] = useState(null);
86
+ const [selectedFolder, setSelectedFolder] = useState(null);
87
+ const [selectedSlug, setSelectedSlug] = useState(
88
+ () => props.data.notes[0]?.slug ?? ""
89
+ );
90
+ const [mobilePanel, setMobilePanel] = useState("graph");
91
+ const [view, setView] = useState({ x: 0, y: 0, scale: 1 });
92
+ useEffect(() => {
93
+ const params = new URLSearchParams(window.location.search);
94
+ const note = params.get("note");
95
+ const tag = params.get("tag");
96
+ const folder = params.get("folder");
97
+ if (note && noteBySlug.has(note)) setSelectedSlug(note);
98
+ if (tag) setSelectedTag(tag);
99
+ if (folder) setSelectedFolder(folder);
100
+ }, [noteBySlug]);
101
+ useEffect(() => {
102
+ const params = new URLSearchParams();
103
+ if (selectedSlug) params.set("note", selectedSlug);
104
+ if (selectedTag) params.set("tag", selectedTag);
105
+ if (selectedFolder) params.set("folder", selectedFolder);
106
+ const next = params.toString() ? `/explore?${params}` : "/explore";
107
+ window.history.replaceState(null, "", next);
108
+ }, [selectedSlug, selectedTag, selectedFolder]);
109
+ const filteredNotes = useMemo(() => {
110
+ const base = props.data.notes.filter((note) => {
111
+ if (selectedTag && !note.tags.includes(selectedTag)) return false;
112
+ if (selectedFolder && note.folder !== selectedFolder) return false;
113
+ return true;
114
+ });
115
+ const searched = query ? searchGardenExplorerNotes(base, query) : base;
116
+ return searched.slice(0, 80);
117
+ }, [props.data.notes, query, selectedTag, selectedFolder]);
118
+ const visibleSlugSet = useMemo(
119
+ () => new Set(filteredNotes.map((note) => note.slug)),
120
+ [filteredNotes]
121
+ );
122
+ const visibleEdges = useMemo(
123
+ () => props.data.edges.filter(
124
+ (edge) => visibleSlugSet.has(edge.source) && visibleSlugSet.has(edge.target)
125
+ ),
126
+ [props.data.edges, visibleSlugSet]
127
+ );
128
+ const graphNodes = useMemo(
129
+ () => layoutNodes(filteredNotes, selectedSlug),
130
+ [filteredNotes, selectedSlug]
131
+ );
132
+ const selectedNote = noteBySlug.get(selectedSlug) ?? filteredNotes[0] ?? null;
133
+ const relatedNotes = useMemo(
134
+ () => selectedNote ? getRelatedNotes(selectedNote, props.data.notes) : [],
135
+ [selectedNote, props.data.notes]
136
+ );
137
+ const selectNote = (slug) => {
138
+ setSelectedSlug(slug);
139
+ setMobilePanel("details");
140
+ };
141
+ const clearFilters = () => {
142
+ setQuery("");
143
+ setSelectedTag(null);
144
+ setSelectedFolder(null);
145
+ };
146
+ const zoom = (delta) => {
147
+ setView((current) => ({
148
+ ...current,
149
+ scale: clamp(current.scale + delta, MIN_SCALE, MAX_SCALE)
150
+ }));
151
+ };
152
+ return /* @__PURE__ */ jsxs("div", { class: "garden-explorer rr-garden-explorer", "data-garden-explorer": true, children: [
153
+ /* @__PURE__ */ jsxs("fieldset", { class: "garden-explorer__mobile-tabs", children: [
154
+ /* @__PURE__ */ jsx("legend", { class: "sr-only", children: "Explorer panels" }),
155
+ ["graph", "explorer", "details"].map((panel) => /* @__PURE__ */ jsx(
156
+ "button",
157
+ {
158
+ type: "button",
159
+ class: "garden-explorer__mobile-tab",
160
+ "aria-pressed": mobilePanel === panel,
161
+ onClick: () => setMobilePanel(panel),
162
+ children: panel
163
+ }
164
+ ))
165
+ ] }),
166
+ /* @__PURE__ */ jsxs("aside", { class: panelClass("explorer", mobilePanel), children: [
167
+ /* @__PURE__ */ jsxs("div", { class: "garden-explorer__section", children: [
168
+ /* @__PURE__ */ jsx("p", { class: "garden-explorer__eyebrow", children: "Explorer" }),
169
+ /* @__PURE__ */ jsxs("label", { class: "garden-explorer__search", children: [
170
+ /* @__PURE__ */ jsx("span", { class: "sr-only", children: "Search notes" }),
171
+ /* @__PURE__ */ jsx(
172
+ "input",
173
+ {
174
+ type: "search",
175
+ value: query,
176
+ placeholder: "Search notes...",
177
+ onInput: (event) => setQuery(event.currentTarget.value)
178
+ }
179
+ )
180
+ ] }),
181
+ /* @__PURE__ */ jsx(
182
+ "button",
183
+ {
184
+ type: "button",
185
+ class: "garden-explorer__text-button",
186
+ onClick: clearFilters,
187
+ children: "Clear filters"
188
+ }
189
+ )
190
+ ] }),
191
+ /* @__PURE__ */ jsx(
192
+ FilterList,
193
+ {
194
+ title: "Tags",
195
+ items: props.data.tags.map((tag) => ({
196
+ key: tag.name,
197
+ label: `#${tag.name}`,
198
+ count: tag.count
199
+ })),
200
+ selectedKey: selectedTag,
201
+ onSelect: (key) => setSelectedTag(key === selectedTag ? null : key)
202
+ }
203
+ ),
204
+ /* @__PURE__ */ jsx(
205
+ FilterList,
206
+ {
207
+ title: "Folders",
208
+ items: props.data.folders.map((folder) => ({
209
+ key: folder.path,
210
+ label: folder.path,
211
+ count: folder.count
212
+ })),
213
+ selectedKey: selectedFolder,
214
+ onSelect: (key) => setSelectedFolder(key === selectedFolder ? null : key)
215
+ }
216
+ ),
217
+ /* @__PURE__ */ jsxs("div", { class: "garden-explorer__section", children: [
218
+ /* @__PURE__ */ jsx("p", { class: "garden-explorer__eyebrow", children: "Notes" }),
219
+ /* @__PURE__ */ jsx(
220
+ NoteList,
221
+ {
222
+ notes: filteredNotes,
223
+ selectedSlug,
224
+ onSelect: selectNote
225
+ }
226
+ )
227
+ ] })
228
+ ] }),
229
+ /* @__PURE__ */ jsxs("section", { class: panelClass("graph", mobilePanel), "aria-label": "Note graph", children: [
230
+ /* @__PURE__ */ jsxs("div", { class: "garden-explorer__graph-toolbar", children: [
231
+ /* @__PURE__ */ jsxs("span", { children: [
232
+ filteredNotes.length,
233
+ " notes"
234
+ ] }),
235
+ /* @__PURE__ */ jsxs("div", { children: [
236
+ /* @__PURE__ */ jsx(
237
+ "button",
238
+ {
239
+ type: "button",
240
+ onClick: () => zoom(-0.2),
241
+ "aria-label": "Zoom out",
242
+ children: "−"
243
+ }
244
+ ),
245
+ /* @__PURE__ */ jsx(
246
+ "button",
247
+ {
248
+ type: "button",
249
+ onClick: () => zoom(0.2),
250
+ "aria-label": "Zoom in",
251
+ children: "+"
252
+ }
253
+ ),
254
+ /* @__PURE__ */ jsx(
255
+ "button",
256
+ {
257
+ type: "button",
258
+ onClick: () => setView({ x: 0, y: 0, scale: 1 }),
259
+ children: "Reset"
260
+ }
261
+ )
262
+ ] })
263
+ ] }),
264
+ /* @__PURE__ */ jsx(
265
+ "svg",
266
+ {
267
+ class: "garden-graph",
268
+ viewBox: `0 0 ${GRAPH_WIDTH} ${GRAPH_HEIGHT}`,
269
+ role: "img",
270
+ "aria-label": "Interactive graph of notes and internal links",
271
+ onWheel: (event) => {
272
+ event.preventDefault();
273
+ zoom(event.deltaY > 0 ? -0.1 : 0.1);
274
+ },
275
+ onPointerMove: (event) => {
276
+ if (event.buttons !== 1) return;
277
+ setView((current) => ({
278
+ ...current,
279
+ x: current.x + event.movementX / current.scale,
280
+ y: current.y + event.movementY / current.scale
281
+ }));
282
+ },
283
+ children: /* @__PURE__ */ jsxs("g", { transform: `translate(${view.x} ${view.y}) scale(${view.scale})`, children: [
284
+ visibleEdges.map((edge) => {
285
+ const source = graphNodes.get(edge.source);
286
+ const target = graphNodes.get(edge.target);
287
+ if (!source || !target) return null;
288
+ return /* @__PURE__ */ jsx(
289
+ "line",
290
+ {
291
+ class: "garden-graph__edge",
292
+ x1: source.x,
293
+ y1: source.y,
294
+ x2: target.x,
295
+ y2: target.y
296
+ },
297
+ `${edge.source}-${edge.target}`
298
+ );
299
+ }),
300
+ filteredNotes.map((note) => {
301
+ const node = graphNodes.get(note.slug);
302
+ if (!node) return null;
303
+ const selected = note.slug === selectedSlug;
304
+ return /* @__PURE__ */ jsx("g", { class: "garden-graph__node", children: /* @__PURE__ */ jsxs(
305
+ "a",
306
+ {
307
+ href: `/explore?note=${encodeURIComponent(note.slug)}`,
308
+ "aria-label": `Select ${note.title}`,
309
+ onClick: (event) => {
310
+ event.preventDefault();
311
+ selectNote(note.slug);
312
+ },
313
+ children: [
314
+ /* @__PURE__ */ jsx(
315
+ "circle",
316
+ {
317
+ cx: node.x,
318
+ cy: node.y,
319
+ r: selected ? 15 : node.radius,
320
+ "data-selected": selected ? "true" : "false"
321
+ }
322
+ ),
323
+ /* @__PURE__ */ jsx("text", { x: node.x + 18, y: node.y + 5, children: note.title })
324
+ ]
325
+ }
326
+ ) }, note.slug);
327
+ })
328
+ ] })
329
+ }
330
+ )
331
+ ] }),
332
+ /* @__PURE__ */ jsx("aside", { class: panelClass("details", mobilePanel), children: selectedNote ? /* @__PURE__ */ jsx(
333
+ NoteDetails,
334
+ {
335
+ note: selectedNote,
336
+ noteBySlug,
337
+ relatedNotes,
338
+ onSelect: selectNote
339
+ }
340
+ ) : /* @__PURE__ */ jsx("p", { class: "garden-explorer__empty", children: "Select a note to inspect links." }) })
341
+ ] });
342
+ }
343
+ function FilterList(props) {
344
+ return /* @__PURE__ */ jsxs("div", { class: "garden-explorer__section", children: [
345
+ /* @__PURE__ */ jsx("p", { class: "garden-explorer__eyebrow", children: props.title }),
346
+ /* @__PURE__ */ jsx("div", { class: "garden-explorer__chips", children: props.items.slice(0, 28).map((item) => /* @__PURE__ */ jsxs(
347
+ "button",
348
+ {
349
+ type: "button",
350
+ class: "garden-explorer__chip",
351
+ "aria-pressed": props.selectedKey === item.key,
352
+ onClick: () => props.onSelect(item.key),
353
+ children: [
354
+ /* @__PURE__ */ jsx("span", { children: item.label }),
355
+ /* @__PURE__ */ jsx("span", { children: item.count })
356
+ ]
357
+ },
358
+ item.key
359
+ )) })
360
+ ] });
361
+ }
362
+ function NoteList(props) {
363
+ return /* @__PURE__ */ jsx("ul", { class: "garden-explorer__note-list", children: props.notes.map((note) => /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsxs(
364
+ "button",
365
+ {
366
+ type: "button",
367
+ "aria-pressed": props.selectedSlug === note.slug,
368
+ onClick: () => props.onSelect(note.slug),
369
+ children: [
370
+ /* @__PURE__ */ jsx("span", { children: note.title }),
371
+ /* @__PURE__ */ jsx("small", { children: note.folder })
372
+ ]
373
+ }
374
+ ) }, note.slug)) });
375
+ }
376
+ function NoteDetails(props) {
377
+ return /* @__PURE__ */ jsxs("div", { class: "garden-explorer__details", children: [
378
+ /* @__PURE__ */ jsx("p", { class: "garden-explorer__eyebrow", children: "Selected Note" }),
379
+ /* @__PURE__ */ jsx("h2", { children: /* @__PURE__ */ jsx("a", { href: props.note.permalink, children: props.note.title }) }),
380
+ /* @__PURE__ */ jsx("p", { class: "garden-explorer__path", children: props.note.slug }),
381
+ props.note.excerpt && /* @__PURE__ */ jsx("p", { children: props.note.excerpt }),
382
+ /* @__PURE__ */ jsx("div", { class: "garden-explorer__tag-row", children: props.note.tags.map((tag) => /* @__PURE__ */ jsxs("span", { children: [
383
+ "#",
384
+ tag
385
+ ] }, tag)) }),
386
+ /* @__PURE__ */ jsx(
387
+ LinkedNoteSection,
388
+ {
389
+ title: "Outgoing Links",
390
+ slugs: props.note.outgoing,
391
+ noteBySlug: props.noteBySlug,
392
+ onSelect: props.onSelect
393
+ }
394
+ ),
395
+ /* @__PURE__ */ jsx(
396
+ LinkedNoteSection,
397
+ {
398
+ title: "Backlinks",
399
+ slugs: props.note.backlinks,
400
+ noteBySlug: props.noteBySlug,
401
+ onSelect: props.onSelect
402
+ }
403
+ ),
404
+ /* @__PURE__ */ jsxs("div", { class: "garden-explorer__section garden-explorer__section--flush", children: [
405
+ /* @__PURE__ */ jsx("p", { class: "garden-explorer__eyebrow", children: "Related Notes" }),
406
+ /* @__PURE__ */ jsx(
407
+ NoteList,
408
+ {
409
+ notes: props.relatedNotes,
410
+ selectedSlug: props.note.slug,
411
+ onSelect: props.onSelect
412
+ }
413
+ )
414
+ ] })
415
+ ] });
416
+ }
417
+ function LinkedNoteSection(props) {
418
+ const notes = props.slugs.map((slug) => props.noteBySlug.get(slug)).filter((note) => note !== void 0);
419
+ return /* @__PURE__ */ jsxs("div", { class: "garden-explorer__section garden-explorer__section--flush", children: [
420
+ /* @__PURE__ */ jsx("p", { class: "garden-explorer__eyebrow", children: props.title }),
421
+ notes.length > 0 ? /* @__PURE__ */ jsx(NoteList, { notes, selectedSlug: "", onSelect: props.onSelect }) : /* @__PURE__ */ jsx("p", { class: "garden-explorer__empty", children: "No notes." })
422
+ ] });
423
+ }
424
+ function layoutNodes(notes, selectedSlug) {
425
+ return layoutRadialGraph(notes, {
426
+ width: GRAPH_WIDTH,
427
+ height: GRAPH_HEIGHT,
428
+ centerSlug: selectedSlug
429
+ });
430
+ }
431
+ function getRelatedNotes(selectedNote, notes) {
432
+ const directSlugs = /* @__PURE__ */ new Set([
433
+ ...selectedNote.outgoing,
434
+ ...selectedNote.backlinks
435
+ ]);
436
+ const selectedTags = new Set(selectedNote.tags);
437
+ return notes.filter((note) => note.slug !== selectedNote.slug).map((note) => ({
438
+ note,
439
+ score: (directSlugs.has(note.slug) ? 8 : 0) + note.tags.filter((tag) => selectedTags.has(tag)).length * 3
440
+ })).filter((item) => item.score > 0).sort(
441
+ (a, b) => b.score - a.score || a.note.title.localeCompare(b.note.title, "ja")
442
+ ).slice(0, 8).map((item) => item.note);
443
+ }
444
+ function panelClass(panel, activePanel) {
445
+ return `garden-explorer__panel garden-explorer__panel--${panel}${panel === activePanel ? " garden-explorer__panel--active" : ""}`;
446
+ }
447
+ function clamp(value, min, max) {
448
+ return Math.min(Math.max(value, min), max);
449
+ }
450
+ export {
451
+ GardenExplorer as default
452
+ };
@@ -0,0 +1,4 @@
1
+ export { default as GardenExplorer } from "./components/garden-explorer.js";
2
+ export type { GardenExplorerData, GardenExplorerEdge, GardenExplorerFolder, GardenExplorerNote, GardenExplorerTag, } from "./src/garden-explorer.js";
3
+ export { getGardenExplorerData } from "./src/garden-explorer.server.js";
4
+ export declare function gardenExplorerPlugin(): import("@riebeckite/core").RiebeckitePlugin<unknown>;