claude-memory-admin 1.0.0 → 1.1.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/public/graph.mjs CHANGED
@@ -1,15 +1,12 @@
1
- // Force-directed SVG graph of the [[wikilink]] edges within one project.
2
- //
3
- // Hand-rolled rather than pulled from a library: the largest project has ~60
4
- // nodes, which a plain spring/repulsion relaxation handles fine, and it keeps
5
- // the app at two dependencies with no bundler.
1
+ import * as ui from '/ui.mjs';
2
+
6
3
 
7
4
  const TYPE_COLORS = {
8
- project: '#5b8def',
9
- feedback: '#d98324',
10
- user: '#8b5cf6',
11
- reference: '#3f9d6d',
12
- unknown: '#9c988d',
5
+ project: 'var(--ui-type-project)',
6
+ feedback: 'var(--ui-type-feedback)',
7
+ user: 'var(--ui-type-user)',
8
+ reference: 'var(--ui-type-reference)',
9
+ unknown: 'var(--ui-fg-subtle)',
13
10
  };
14
11
 
15
12
  function colorFor(type) {
@@ -21,7 +18,6 @@ function simulate(nodes, edges, { width, height, iterations = 320, repulsion = 1
21
18
  const k = Math.sqrt(area / Math.max(nodes.length, 1)) * 0.72;
22
19
  const index = new Map(nodes.map((n, i) => [n.id, i]));
23
20
 
24
- // Deterministic ring start: same project always lays out the same way.
25
21
  nodes.forEach((node, i) => {
26
22
  const angle = (i / nodes.length) * Math.PI * 2;
27
23
  const radius = Math.min(width, height) * 0.32 * (0.55 + ((i * 37) % 100) / 220);
@@ -66,8 +62,6 @@ function simulate(nodes, edges, { width, height, iterations = 320, repulsion = 1
66
62
  b.dx += fx; b.dy += fy;
67
63
  }
68
64
 
69
- // Pull toward the centre, proportional to distance, so disconnected
70
- // components stay in frame instead of drifting outward forever.
71
65
  for (const node of nodes) {
72
66
  node.dx += (width / 2 - node.x) * 0.006;
73
67
  node.dy += (height / 2 - node.y) * 0.006;
@@ -81,14 +75,10 @@ function simulate(nodes, edges, { width, height, iterations = 320, repulsion = 1
81
75
  temperature *= 0.99;
82
76
  }
83
77
 
84
- // Rescale the settled layout to fill the viewport. Clamping to the walls
85
- // during the simulation would instead flatten everything onto the border,
86
- // which is what makes hand-rolled force layouts look broken.
87
78
  fitToBox(nodes, width, height, 46);
88
79
  return nodes;
89
80
  }
90
81
 
91
- /** Map the settled positions onto the drawing area, preserving aspect ratio. */
92
82
  function fitToBox(nodes, width, height, pad) {
93
83
  const xs = nodes.map((n) => n.x);
94
84
  const ys = nodes.map((n) => n.y);
@@ -98,9 +88,6 @@ function fitToBox(nodes, width, height, pad) {
98
88
  const maxY = Math.max(...ys);
99
89
  const spanX = maxX - minX || 1;
100
90
  const spanY = maxY - minY || 1;
101
- // Scale each axis to the box, but cap how far they may differ: a uniform
102
- // scale wastes half the canvas on a wide panel, and an uncapped one stretches
103
- // the graph into something misleading.
104
91
  let scaleX = (width - pad * 2) / spanX;
105
92
  let scaleY = (height - pad * 2) / spanY;
106
93
  const MAX_ANISOTROPY = 1.45;
@@ -115,7 +102,6 @@ function fitToBox(nodes, width, height, pad) {
115
102
  }
116
103
  }
117
104
 
118
- /** Connected components, so each cluster can be laid out on its own. */
119
105
  function findComponents(nodes, edges) {
120
106
  const adjacency = new Map(nodes.map((n) => [n.id, []]));
121
107
  for (const edge of edges) {
@@ -151,16 +137,6 @@ function boundsOf(nodes) {
151
137
  return { minX: Math.min(...xs), maxX: Math.max(...xs), minY: Math.min(...ys), maxY: Math.max(...ys) };
152
138
  }
153
139
 
154
- /**
155
- * Lay out each connected component on its own, then pack the components into
156
- * the canvas.
157
- *
158
- * Simulating everything in one field looks wrong as soon as a project has more
159
- * than one cluster: unconnected components drift apart, and scaling the result
160
- * to fit then squashes the largest cluster into an unreadable knot. Packing
161
- * gives every cluster the same generous internal spacing regardless of how many
162
- * there are.
163
- */
164
140
  function layoutComponents(nodes, edges, { width, height, repulsion }) {
165
141
  const byId = new Map(nodes.map((n) => [n.id, n]));
166
142
  const groups = findComponents(nodes, edges)
@@ -171,7 +147,6 @@ function layoutComponents(nodes, edges, { width, height, repulsion }) {
171
147
  for (const group of groups) {
172
148
  const ids = new Set(group.map((n) => n.id));
173
149
  const inner = edges.filter((e) => ids.has(e.from) && ids.has(e.to));
174
- // A box roughly proportional to the node count keeps density even.
175
150
  const side = Math.max(180, Math.sqrt(group.length) * 190);
176
151
  simulate(group, inner, { width: side, height: side, iterations: 420, repulsion });
177
152
  const bounds = boundsOf(group);
@@ -183,7 +158,6 @@ function layoutComponents(nodes, edges, { width, height, repulsion }) {
183
158
  });
184
159
  }
185
160
 
186
- // Shelf packing: fill a row until it would overflow, then start a new one.
187
161
  const gap = 26;
188
162
  let cursorX = 0;
189
163
  let cursorY = 0;
@@ -206,8 +180,6 @@ function layoutComponents(nodes, edges, { width, height, repulsion }) {
206
180
  }
207
181
  }
208
182
 
209
- // Reuse the same fit the single-component path uses, so one big cluster still
210
- // fills the canvas instead of being shrunk to fit a square working box.
211
183
  fitToBox(nodes, width, height, 40);
212
184
  }
213
185
 
@@ -218,10 +190,6 @@ function svgEl(tag, attrs = {}) {
218
190
  return el;
219
191
  }
220
192
 
221
- /**
222
- * Render the graph into `container`. Dangling wikilink targets are drawn as
223
- * hollow nodes so a broken link is visible rather than simply absent.
224
- */
225
193
  export function renderGraph(container, graph, { onSelect, onSpreadChange, selected, spread = 1.6 } = {}) {
226
194
  container.textContent = '';
227
195
 
@@ -234,8 +202,7 @@ export function renderGraph(container, graph, { onSelect, onSpreadChange, select
234
202
 
235
203
  if (nodes.length === 0) {
236
204
  const note = document.createElement('p');
237
- note.className = 'muted';
238
- note.style.padding = '40px';
205
+ note.className = ui.graphEmpty;
239
206
  note.textContent = 'No memories to graph yet.';
240
207
  container.append(note);
241
208
  return;
@@ -250,9 +217,6 @@ export function renderGraph(container, graph, { onSelect, onSpreadChange, select
250
217
  const width = Math.max(container.clientWidth || 900, 480);
251
218
  const baseHeight = 620;
252
219
 
253
- // Nodes with no links at all are laid out in their own strip rather than
254
- // left in the simulation: a single unlinked node drifts to the far edge and
255
- // squashes everything else when the layout is scaled to fit.
256
220
  const degree = new Map(nodes.map((n) => [n.id, 0]));
257
221
  for (const edge of edges) {
258
222
  degree.set(edge.from, (degree.get(edge.from) || 0) + 1);
@@ -261,9 +225,6 @@ export function renderGraph(container, graph, { onSelect, onSpreadChange, select
261
225
  const linked = nodes.filter((n) => degree.get(n.id) > 0);
262
226
  const unlinked = nodes.filter((n) => !degree.get(n.id));
263
227
 
264
- // The strip grows with the number of rows it has to hold. A fixed height
265
- // overflowed the canvas as soon as a project had more than one row of
266
- // unlinked notes, dropping them behind the legend.
267
228
  const perRow = Math.max(1, Math.floor((width - 120) / 160));
268
229
  const stripRows = unlinked.length ? Math.ceil(unlinked.length / perRow) : 0;
269
230
  const strip = stripRows ? 34 + stripRows * 46 : 0;
@@ -302,15 +263,13 @@ export function renderGraph(container, graph, { onSelect, onSpreadChange, select
302
263
 
303
264
  const position = new Map(nodes.map((n) => [n.id, n]));
304
265
  const edgeGroup = svgEl('g', { stroke: 'currentColor', 'stroke-opacity': '.28', 'marker-end': 'url(#arrow)' });
305
- // Kept as pairs rather than relying on child order: edges whose endpoints are
306
- // missing are skipped, so the indices would not line up.
307
266
  const drawnEdges = [];
308
267
  for (const edge of edges) {
309
268
  const a = position.get(edge.from);
310
269
  const b = position.get(edge.to);
311
270
  if (!a || !b) continue;
312
271
  const line = svgEl('line', {
313
- class: 'edge',
272
+ class: ui.graphEdge,
314
273
  x1: a.x, y1: a.y, x2: b.x, y2: b.y,
315
274
  'stroke-dasharray': String(edge.to).startsWith('ghost:') ? '4 3' : '',
316
275
  });
@@ -338,28 +297,22 @@ export function renderGraph(container, graph, { onSelect, onSpreadChange, select
338
297
  }
339
298
 
340
299
  for (const node of nodes) {
341
- const group = svgEl('g', { class: `node${node.id === selected ? ' hi' : ''}`, transform: `translate(${node.x},${node.y})` });
300
+ const group = svgEl('g', { class: ui.graphNode(node.id === selected), transform: `translate(${node.x},${node.y})` });
342
301
  const radius = node.ghost ? 6 : 7 + Math.min(node.degree, 8) * 1.1;
343
302
 
344
- // A generous invisible disc widening the target: at this density the drawn
345
- // circles are only a few pixels across and are painfully hard to hit.
346
- // Handlers go on the group, not on this disc - the visible circle paints
347
- // over its centre, so a disc-level listener would only catch the ring
348
- // around the dot and clicks on the node itself would do nothing.
349
- const hit = svgEl('circle', { class: 'hit', r: Math.max(radius + 9, 15) });
303
+ const hit = svgEl('circle', { class: ui.graphHit, r: Math.max(radius + 9, 15) });
350
304
  group.append(hit);
351
305
 
352
306
  const circle = svgEl('circle', {
353
307
  r: radius,
354
308
  fill: node.ghost ? 'transparent' : colorFor(node.type),
355
- stroke: node.ghost ? '#b3261e' : node.status === 'orphan' ? '#a86a12' : 'rgba(0,0,0,.18)',
309
+ stroke: node.ghost ? 'var(--ui-danger)' : node.status === 'orphan' ? 'var(--ui-warn)' : 'var(--ui-line-strong)',
356
310
  'stroke-dasharray': node.ghost ? '3 2' : node.status === 'orphan' ? '3 2' : '',
357
311
  });
358
312
  const label = svgEl('text', {
359
313
  y: radius + 10,
360
314
  'text-anchor': 'middle',
361
- // Halo in the panel colour so overlapping labels stay readable.
362
- stroke: 'var(--panel)',
315
+ stroke: 'var(--ui-surface)',
363
316
  'stroke-width': '2.5',
364
317
  'paint-order': 'stroke',
365
318
  'stroke-linejoin': 'round',
@@ -378,8 +331,6 @@ export function renderGraph(container, graph, { onSelect, onSpreadChange, select
378
331
  group.addEventListener('click', (event) => { event.stopPropagation(); onSelect(node.id); });
379
332
  }
380
333
 
381
- // Hovering focuses the node and its neighbours: the only practical way to
382
- // read a dense cluster without zooming into it first.
383
334
  group.addEventListener('pointerenter', () => focusOn(node.id));
384
335
  group.addEventListener('pointerleave', () => focusOn(null));
385
336
 
@@ -400,7 +351,6 @@ export function renderGraph(container, graph, { onSelect, onSpreadChange, select
400
351
  }
401
352
  }
402
353
 
403
- // Pan and zoom.
404
354
  let scale = 1;
405
355
  let panX = 0;
406
356
  let panY = 0;
@@ -419,9 +369,6 @@ export function renderGraph(container, graph, { onSelect, onSpreadChange, select
419
369
  apply();
420
370
  }, { passive: false });
421
371
 
422
- // Panning only engages once the pointer has actually moved. Capturing on
423
- // pointerdown instead would retarget the pointer to the <svg>, and the click
424
- // on a node would never reach it - which is why nodes were unclickable.
425
372
  const DRAG_THRESHOLD = 4;
426
373
  let pending = null;
427
374
  let panning = false;
@@ -458,7 +405,6 @@ export function renderGraph(container, graph, { onSelect, onSpreadChange, select
458
405
  svg.addEventListener('pointerup', endDrag);
459
406
  svg.addEventListener('pointercancel', endDrag);
460
407
 
461
- // Zoom helpers used by the toolbar.
462
408
  const zoomBy = (factor) => {
463
409
  const next = Math.max(0.3, Math.min(6, scale * factor));
464
410
  const cx = width / 2;
@@ -473,10 +419,11 @@ export function renderGraph(container, graph, { onSelect, onSpreadChange, select
473
419
  container.append(svg);
474
420
 
475
421
  const toolbar = document.createElement('div');
476
- toolbar.className = 'graph-toolbar';
422
+ toolbar.className = ui.graphToolbar;
477
423
 
478
424
  const makeButton = (label, title, onClick) => {
479
425
  const button = document.createElement('button');
426
+ button.className = ui.graphButton;
480
427
  button.textContent = label;
481
428
  button.title = title;
482
429
  button.onclick = onClick;
@@ -489,14 +436,15 @@ export function renderGraph(container, graph, { onSelect, onSpreadChange, select
489
436
  );
490
437
 
491
438
  const spreadLabel = document.createElement('label');
439
+ spreadLabel.className = ui.graphLabel;
492
440
  spreadLabel.title = 'How far apart the layout pushes nodes';
493
441
  const slider = document.createElement('input');
442
+ slider.className = ui.graphRange;
494
443
  slider.type = 'range';
495
444
  slider.min = '1';
496
445
  slider.max = '4';
497
446
  slider.step = '0.2';
498
447
  slider.value = String(spread);
499
- // Re-laying out is cheap here (<70 nodes) so it can happen on release.
500
448
  slider.addEventListener('change', () => {
501
449
  if (onSpreadChange) onSpreadChange(Number(slider.value));
502
450
  });
@@ -505,13 +453,21 @@ export function renderGraph(container, graph, { onSelect, onSpreadChange, select
505
453
  container.append(toolbar);
506
454
 
507
455
  const legend = document.createElement('div');
508
- legend.className = 'legend';
509
- const items = [
510
- ...Object.entries(TYPE_COLORS).filter(([type]) => real.some((n) => n.type === type)),
511
- ].map(([type, color]) => `<span><i style="background:${color}"></i>${type}</span>`);
512
- items.push('<span><i style="border:1.5px dashed #a86a12"></i>orphan</span>');
513
- if (ghosts.length) items.push('<span><i style="border:1.5px dashed #b3261e"></i>missing target</span>');
514
- legend.innerHTML = items.join('');
456
+ legend.className = ui.graphLegend;
457
+ const entries = Object.entries(TYPE_COLORS)
458
+ .filter(([type]) => real.some((n) => n.type === type))
459
+ .map(([type, color]) => [type, `background:${color}`]);
460
+ entries.push(['orphan', 'border:1.5px dashed var(--ui-warn)']);
461
+ if (ghosts.length) entries.push(['missing target', 'border:1.5px dashed var(--ui-danger)']);
462
+ for (const [label, swatchStyle] of entries) {
463
+ const item = document.createElement('span');
464
+ item.className = ui.graphLegendItem;
465
+ const swatch = document.createElement('i');
466
+ swatch.className = ui.graphLegendSwatch;
467
+ swatch.style.cssText = swatchStyle;
468
+ item.append(swatch, document.createTextNode(label));
469
+ legend.append(item);
470
+ }
515
471
  container.append(legend);
516
472
 
517
473
  return byName;
package/public/index.html CHANGED
@@ -4,62 +4,67 @@
4
4
  <meta charset="utf-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
6
  <title>Claude Memory Admin</title>
7
+ <script>
8
+ document.documentElement.dataset.theme =
9
+ localStorage.getItem('theme')
10
+ || (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
11
+ </script>
7
12
  <link rel="stylesheet" href="/styles.css">
8
13
  <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'><text y='13' font-size='13'>🧠</text></svg>">
9
14
  </head>
10
15
  <body>
11
16
  <div id="app">
12
- <aside id="sidebar">
13
- <header class="sidebar-head">
14
- <button id="collapse" class="icon-btn" title="Hide the project list" aria-label="Hide the project list">&#9668;</button>
15
- <h1>Memory Admin</h1>
16
- <p id="root-path" class="muted mono"></p>
17
- <input id="search" type="search" placeholder="Search all memories ( / )" autocomplete="off" spellcheck="false">
18
- <label class="toggle">
19
- <input type="checkbox" id="show-all"> show projects without memory
17
+ <aside id="sidebar" data-ui="sidebar">
18
+ <header data-ui="sidebarHead">
19
+ <button id="collapse" data-ui="iconButton" title="Hide the project list" aria-label="Hide the project list">&#9668;</button>
20
+ <h1 data-ui="sidebarTitle">memory<span data-ui="promptCaret">/</span>admin</h1>
21
+ <p id="root-path" data-ui="sidebarRoot"></p>
22
+ <input id="search" type="search" data-ui="searchInput" placeholder="Search all memories ( / )" autocomplete="off" spellcheck="false">
23
+ <label data-ui="toggleLabel">
24
+ <input type="checkbox" id="show-all" data-ui="checkbox"> show projects without memory
20
25
  </label>
21
26
  </header>
22
- <nav id="project-list" aria-label="Projects"></nav>
23
- <footer class="sidebar-foot">
24
- <a href="https://bitspire.ch/" target="_blank" rel="noopener noreferrer">
27
+ <nav id="project-list" data-ui="sidebarList" aria-label="Projects"></nav>
28
+ <footer data-ui="sidebarFoot">
29
+ <a href="https://bitspire.ch/" target="_blank" rel="noopener noreferrer" data-ui="sidebarCredit">
25
30
  <span>Brought to you by</span>
26
- <img src="/assets/bitspire-logo.webp" alt="BitSpire" width="96">
31
+ <img src="/assets/bitspire-logo.webp" alt="BitSpire" width="96" data-ui="sidebarLogo">
27
32
  </a>
28
33
  </footer>
29
34
  </aside>
30
35
 
31
- <button id="expand" class="expand-tab" title="Show the project list" aria-label="Show the project list" hidden>&#9658;</button>
36
+ <button id="expand" data-ui="expandHandle" title="Show the project list" aria-label="Show the project list" hidden>&#9658;</button>
32
37
 
33
- <main id="main">
34
- <div id="empty-state" class="empty">
35
- <h2>Pick a project</h2>
36
- <p>Each project keeps its memories in <code>~/.claude/projects/&lt;slug&gt;/memory/</code>, indexed by <code>MEMORY.md</code>.</p>
38
+ <main id="main" data-ui="main">
39
+ <div id="empty-state" data-ui="emptyState">
40
+ <h2 data-ui="emptyTitle">Pick a project</h2>
41
+ <p>Each project keeps its memories in <code data-ui="inlineCode">~/.claude/projects/&lt;slug&gt;/memory/</code>, indexed by <code data-ui="inlineCode">MEMORY.md</code>.</p>
37
42
  </div>
38
43
 
39
44
  <div id="search-view" hidden>
40
- <header id="search-head">
41
- <h2 id="search-title"></h2>
42
- <button id="search-clear" class="btn small">Clear</button>
45
+ <header id="search-head" data-ui="searchHead">
46
+ <h2 id="search-title" data-ui="searchTitle"></h2>
47
+ <button id="search-clear" data-ui="buttonSmall">Clear</button>
43
48
  </header>
44
- <section id="search-results"></section>
49
+ <section id="search-results" data-ui="searchResults"></section>
45
50
  </div>
46
51
 
47
52
  <div id="project-view" hidden>
48
- <header id="project-head">
49
- <div class="project-head-row">
50
- <h2 id="project-title"></h2>
51
- <button id="delete-project" class="btn danger small">Delete all memory</button>
53
+ <header id="project-head" data-ui="projectHead">
54
+ <div data-ui="projectHeadRow">
55
+ <h2 id="project-title" data-ui="projectTitle"></h2>
56
+ <button id="delete-project" data-ui="buttonDangerSmall">Delete all memory</button>
52
57
  </div>
53
- <p id="project-sub" class="muted mono"></p>
54
- <div id="tabs" role="tablist"></div>
58
+ <p id="project-sub" data-ui="projectSub"></p>
59
+ <div id="tabs" data-ui="tabBar" role="tablist"></div>
55
60
  </header>
56
- <section id="tab-content"></section>
61
+ <section id="tab-content" data-ui="tabContent"></section>
57
62
  </div>
58
63
  </main>
59
64
  </div>
60
65
 
61
66
  <div id="modal-root"></div>
62
- <div id="toast-root" aria-live="polite"></div>
67
+ <div id="toast-root" data-ui="toastRoot" aria-live="polite"></div>
63
68
 
64
69
  <script type="module" src="/app.mjs"></script>
65
70
  </body>
@@ -0,0 +1,74 @@
1
+ import { marked } from '/vendor/marked.js';
2
+ import DOMPurify from '/vendor/purify.js';
3
+ import * as ui from '/ui.mjs';
4
+
5
+ marked.setOptions({ gfm: true, breaks: false });
6
+
7
+ const WIKILINK = /\[\[([^\]|]+)(?:\|([^\]]*))?\]\]/g;
8
+ const PLACEHOLDER = /%%WIKI(\d+)%%/;
9
+ const PLACEHOLDER_EXACT = /^%%WIKI(\d+)%%$/;
10
+ const PLACEHOLDER_SPLIT = /(%%WIKI\d+%%)/;
11
+ const LOCAL_MARKDOWN = /^[^/#?][^:]*\.md$/i;
12
+
13
+ function extractWikilinks(text) {
14
+ const tokens = [];
15
+ const masked = text.replace(WIKILINK, (_, target, alias) => {
16
+ const key = `%%WIKI${tokens.length}%%`;
17
+ tokens.push({ target: target.trim(), alias: (alias || '').trim() });
18
+ return key;
19
+ });
20
+ return { tokens, masked };
21
+ }
22
+
23
+ function linkButton(token, resolved) {
24
+ const button = document.createElement('button');
25
+ button.className = ui.wikilink(Boolean(resolved));
26
+ button.textContent = token.alias || token.target;
27
+ button.title = resolved ? `Go to ${token.target}` : `No memory named "${token.target}"`;
28
+ if (resolved) button.onclick = resolved.open;
29
+ return button;
30
+ }
31
+
32
+ function rehydrateWikilinks(container, tokens, resolveWikilink) {
33
+ const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT);
34
+ const texts = [];
35
+ while (walker.nextNode()) texts.push(walker.currentNode);
36
+
37
+ for (const textNode of texts) {
38
+ if (!PLACEHOLDER.test(textNode.nodeValue)) continue;
39
+ const fragment = document.createDocumentFragment();
40
+ for (const part of textNode.nodeValue.split(PLACEHOLDER_SPLIT)) {
41
+ const match = part.match(PLACEHOLDER_EXACT);
42
+ if (!match) {
43
+ if (part) fragment.append(document.createTextNode(part));
44
+ continue;
45
+ }
46
+ const token = tokens[Number(match[1])];
47
+ fragment.append(linkButton(token, resolveWikilink(token.target)));
48
+ }
49
+ textNode.replaceWith(fragment);
50
+ }
51
+ }
52
+
53
+ function rewireAnchors(container, resolveHref) {
54
+ for (const anchor of container.querySelectorAll('a[href]')) {
55
+ const href = anchor.getAttribute('href');
56
+ if (!LOCAL_MARKDOWN.test(href)) {
57
+ anchor.target = '_blank';
58
+ anchor.rel = 'noopener noreferrer';
59
+ continue;
60
+ }
61
+ const resolved = resolveHref(href);
62
+ anchor.className = ui.indexLink(Boolean(resolved));
63
+ anchor.removeAttribute('href');
64
+ anchor.title = resolved ? `Go to ${href}` : `${href} does not exist`;
65
+ if (resolved) anchor.onclick = resolved.open;
66
+ }
67
+ }
68
+
69
+ export function renderMarkdown(container, text, { resolveWikilink, resolveHref } = {}) {
70
+ const { tokens, masked } = extractWikilinks(text);
71
+ container.innerHTML = DOMPurify.sanitize(marked.parse(masked));
72
+ if (tokens.length && resolveWikilink) rehydrateWikilinks(container, tokens, resolveWikilink);
73
+ if (resolveHref) rewireAnchors(container, resolveHref);
74
+ }