@dirwell/theme-ledger 0.0.0-stage → 0.0.1-alpha.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,639 @@
1
+ <script>
2
+ import { onMount, tick, untrack } from "svelte";
3
+ import { entryIcon } from "./shared/icons.mjs";
4
+ import { loadListing } from "@dirwell/core/client";
5
+ import {
6
+ fetchThemePage,
7
+ applyThemeMetadata,
8
+ restoreThemePage,
9
+ } from "./shared/navigation.mjs";
10
+ import {
11
+ visibleRecords,
12
+ directoryLike,
13
+ entryType,
14
+ size,
15
+ modified,
16
+ fuzzy,
17
+ } from "./shared/records.mjs";
18
+ let { model: initialModel, header, row, footer } = $props();
19
+ const snapshot = untrack(() =>
20
+ restoreThemePage(initialModel, Boolean(import.meta.hot)),
21
+ );
22
+ let model = $state.raw(snapshot),
23
+ entries = $state.raw(snapshot.entries),
24
+ query = $state(""),
25
+ types = $state(["directory", "file", "link"]),
26
+ sort = $state.raw(snapshot.sort),
27
+ scheme = $state("system"),
28
+ selected = $state(null),
29
+ ready = $state(false),
30
+ busy = $state(false),
31
+ error = $state(""),
32
+ timestamp = $state(null),
33
+ composing = $state(false),
34
+ scrollTop = $state(0),
35
+ globalOpen = $state(false),
36
+ globalQuery = $state(""),
37
+ globalRows = $state.raw([]),
38
+ globalStatus = $state("Enter a search to begin."),
39
+ pendingUpdates = $state(null);
40
+ let heights = $state.raw(new Map());
41
+ let updatesHref = $derived(
42
+ ready && model.updatesHref
43
+ ? new URL(model.updatesHref, document.baseURI).href
44
+ : null,
45
+ );
46
+ let complete = $derived(entries.length === model.total),
47
+ rows = $derived(
48
+ visibleRecords(entries, composing ? "" : query, types, sort),
49
+ ),
50
+ virtual = $derived(ready && rows.length > 500);
51
+ let offsets = $derived.by(() => {
52
+ const values = [0];
53
+ for (const entry of rows)
54
+ values.push(values.at(-1) + (heights.get(entry.id) ?? 76));
55
+ return values;
56
+ });
57
+ let start = $derived.by(() => {
58
+ if (!virtual) return 0;
59
+ let low = 0,
60
+ high = rows.length;
61
+ while (low < high) {
62
+ const mid = (low + high) >> 1;
63
+ if (offsets[mid + 1] <= scrollTop) low = mid + 1;
64
+ else high = mid;
65
+ }
66
+ return Math.max(0, low - 5);
67
+ }),
68
+ end = $derived(virtual ? Math.min(rows.length, start + 35) : rows.length);
69
+ function measure(node, id) {
70
+ if (typeof ResizeObserver === "undefined") return;
71
+ const observer = new ResizeObserver(() => {
72
+ const value = node.getBoundingClientRect().height;
73
+ if (value > 0 && heights.get(id) !== value) {
74
+ const next = new Map(heights);
75
+ next.set(id, value);
76
+ heights = next;
77
+ }
78
+ });
79
+ observer.observe(node);
80
+ return { destroy: () => observer.disconnect() };
81
+ }
82
+ let list = $state(),
83
+ searchInput = $state(),
84
+ globalInput = $state(),
85
+ timeDialog = $state(),
86
+ globalDialog = $state();
87
+ let navigation = 0,
88
+ abort,
89
+ archiveAbort,
90
+ archiveRequest = 0;
91
+ const isDirectory = directoryLike;
92
+ function safeHref(record) {
93
+ if (
94
+ typeof location !== "undefined" &&
95
+ location.protocol === "file:" &&
96
+ isDirectory(record) &&
97
+ !record.exitsExplorer &&
98
+ record.href?.endsWith("/")
99
+ )
100
+ return record.href + "index.html";
101
+ return record.href;
102
+ }
103
+ async function hydrateData() {
104
+ if (complete || !model.manifestHref || location.protocol === "file:")
105
+ return;
106
+ const local = abort,
107
+ token = navigation;
108
+ busy = true;
109
+ try {
110
+ const loaded = await loadListing(
111
+ new URL(model.manifestHref, document.baseURI).href,
112
+ {
113
+ signal: local.signal,
114
+ cache: model.updatesHref ? "no-store" : "default",
115
+ },
116
+ );
117
+ if (token === navigation && !local.signal.aborted)
118
+ entries = loaded.entries;
119
+ } catch (cause) {
120
+ if (token === navigation && !local.signal.aborted)
121
+ error =
122
+ "Could not load complete directory data. Native page links remain available.";
123
+ } finally {
124
+ if (token === navigation) busy = false;
125
+ }
126
+ }
127
+ async function navigate(event, record) {
128
+ const dataHref = record.presentation?.folderDataHref;
129
+ if (
130
+ record.exitsExplorer ||
131
+ event?.altKey ||
132
+ event?.ctrlKey ||
133
+ event?.metaKey ||
134
+ event?.shiftKey ||
135
+ event?.button > 0 ||
136
+ typeof dataHref !== "string" ||
137
+ location.protocol === "file:"
138
+ )
139
+ return;
140
+ event?.preventDefault();
141
+ await openFolder(
142
+ dataHref,
143
+ new URL(record.href, document.baseURI).href,
144
+ true,
145
+ );
146
+ }
147
+ async function openFolder(dataHref, url, push, refresh = false) {
148
+ const token = ++navigation;
149
+ abort?.abort();
150
+ const local = (abort = new AbortController());
151
+ busy = true;
152
+ error = "";
153
+ try {
154
+ const next = await fetchThemePage(
155
+ dataHref,
156
+ url,
157
+ "ledger",
158
+ local.signal,
159
+ refresh,
160
+ );
161
+ let nextEntries = next.entries;
162
+ if (refresh && next.manifestHref && nextEntries.length !== next.total) {
163
+ nextEntries = (
164
+ await loadListing(next.manifestHref, {
165
+ signal: local.signal,
166
+ cache: "no-store",
167
+ })
168
+ ).entries;
169
+ }
170
+ if (token !== navigation || local.signal.aborted) return;
171
+ model = next;
172
+ entries = nextEntries;
173
+ if (!refresh) {
174
+ query = "";
175
+ selected = null;
176
+ scrollTop = 0;
177
+ }
178
+ if (push) history.pushState({ dataHref: next.dataHref }, "", url);
179
+ applyThemeMetadata(next, url);
180
+ await hydrateData();
181
+ if (!refresh) window.scrollTo({ top: 0 });
182
+ } catch (cause) {
183
+ if (token === navigation && !local.signal.aborted)
184
+ error =
185
+ "Could not open this folder. Open its native link or try again.";
186
+ } finally {
187
+ if (token === navigation) busy = false;
188
+ }
189
+ }
190
+ function navigateLink(event, href, dataHref) {
191
+ if (
192
+ event?.altKey ||
193
+ event?.ctrlKey ||
194
+ event?.metaKey ||
195
+ event?.shiftKey ||
196
+ event?.button > 0 ||
197
+ location.protocol === "file:"
198
+ )
199
+ return;
200
+ event?.preventDefault();
201
+ openFolder(dataHref, new URL(href, document.baseURI).href, true);
202
+ }
203
+ async function move(index) {
204
+ const row = rows[Math.max(0, Math.min(rows.length - 1, index))];
205
+ if (!row) return;
206
+ selected = row.id;
207
+ if (virtual) {
208
+ scrollTop = Math.max(0, offsets[index] - 100);
209
+ if (list)
210
+ window.scrollTo({
211
+ top: list.getBoundingClientRect().top + window.scrollY + scrollTop,
212
+ });
213
+ }
214
+ await tick();
215
+ list
216
+ ?.querySelector(`[data-order="${index}"] a`)
217
+ ?.focus({ preventScroll: true });
218
+ }
219
+ function keyboard(event) {
220
+ if (event.isComposing || event.altKey || event.ctrlKey || event.metaKey)
221
+ return;
222
+ if (globalOpen) {
223
+ if (event.key === "Escape") {
224
+ event.preventDefault();
225
+ globalOpen = false;
226
+ }
227
+ return;
228
+ }
229
+ if (timestamp) {
230
+ if (event.key === "Escape") timestamp = null;
231
+ return;
232
+ }
233
+ const editable = /INPUT|TEXTAREA|SELECT/.test(event.target?.tagName ?? "");
234
+ if (event.key === "/" && !editable) {
235
+ event.preventDefault();
236
+ searchInput?.focus();
237
+ return;
238
+ }
239
+ if (editable) {
240
+ if (event.key === "Escape") {
241
+ query = "";
242
+ searchInput?.blur();
243
+ }
244
+ return;
245
+ }
246
+ if (event.key === "Backspace" && model.parentHref) {
247
+ event.preventDefault();
248
+ if (location.protocol === "file:") location.href = model.parentHref;
249
+ else
250
+ openFolder(
251
+ model.parentDataHref,
252
+ new URL(model.parentHref, document.baseURI).href,
253
+ true,
254
+ );
255
+ return;
256
+ }
257
+ const index = rows.findIndex((row) => row.id === selected);
258
+ if (["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) {
259
+ event.preventDefault();
260
+ move(
261
+ event.key === "Home"
262
+ ? 0
263
+ : event.key === "End"
264
+ ? rows.length - 1
265
+ : index + (event.key === "ArrowDown" ? 1 : -1),
266
+ );
267
+ }
268
+ }
269
+ function updateScheme(value) {
270
+ scheme = value;
271
+ try {
272
+ localStorage.setItem("dirwell-color-scheme", value);
273
+ } catch {}
274
+ }
275
+ async function searchArchive() {
276
+ const token = ++archiveRequest;
277
+ archiveAbort?.abort();
278
+ const local = (archiveAbort = new AbortController());
279
+ globalRows = [];
280
+ const query = globalQuery.trim();
281
+ if (!query) {
282
+ globalStatus = "Enter a search to begin.";
283
+ return;
284
+ }
285
+ globalStatus = "Searching archive…";
286
+ try {
287
+ const url = new URL(model.searchIndexHref, document.baseURI);
288
+ const response = await fetch(url, { signal: local.signal });
289
+ if (!response.ok) throw new Error("Unavailable archive");
290
+ const index = await response.json();
291
+ if (
292
+ index.version !== 3 ||
293
+ !Array.isArray(index.shards) ||
294
+ !Number.isSafeInteger(index.count) ||
295
+ index.count > 100000
296
+ )
297
+ throw new Error("Unsupported archive");
298
+ const all = [];
299
+ for (const shard of index.shards) {
300
+ if (typeof shard !== "string") throw new Error("Invalid shard");
301
+ const shardUrl = new URL(shard, url);
302
+ if (shardUrl.origin !== url.origin) throw new Error("Unsafe shard");
303
+ const chunk = await fetch(shardUrl, { signal: local.signal });
304
+ if (!chunk.ok) throw new Error("Unavailable shard");
305
+ const data = await chunk.json();
306
+ if (
307
+ !Array.isArray(data.entries) ||
308
+ all.length + data.entries.length > 100000
309
+ )
310
+ throw new Error("Invalid archive");
311
+ for (const record of data.entries) {
312
+ if (
313
+ typeof record.name !== "string" ||
314
+ typeof record.path !== "string" ||
315
+ typeof record.exitsExplorer !== "boolean"
316
+ )
317
+ throw new Error("Invalid archive record");
318
+ const href = record.href === null ? null : new URL(record.href, url);
319
+ if (href && !["http:", "https:"].includes(href.protocol))
320
+ throw new Error("Unsupported archive link");
321
+ all.push({
322
+ name: record.name,
323
+ path: record.path,
324
+ href: href?.href ?? null,
325
+ exitsExplorer: record.exitsExplorer,
326
+ });
327
+ }
328
+ }
329
+ const matches = all
330
+ .map((record) => ({
331
+ record,
332
+ score: fuzzy(query, record.name + " " + record.path),
333
+ }))
334
+ .filter((item) => item.score !== null)
335
+ .sort(
336
+ (a, b) =>
337
+ b.score - a.score || a.record.path.localeCompare(b.record.path),
338
+ );
339
+ if (token !== archiveRequest || local.signal.aborted) return;
340
+ globalRows = matches.slice(0, 100).map((item) => item.record);
341
+ globalStatus =
342
+ matches.length > 100
343
+ ? "Showing 100 of " + matches.length + " results"
344
+ : matches.length + " results";
345
+ } catch {
346
+ if (token === archiveRequest && !local.signal.aborted)
347
+ globalStatus = "Could not load archive search. Try again.";
348
+ }
349
+ }
350
+ onMount(() => {
351
+ ready = true;
352
+ abort = new AbortController();
353
+ try {
354
+ const stored = localStorage.getItem("dirwell-color-scheme");
355
+ if (["system", "light", "dark"].includes(stored)) scheme = stored;
356
+ } catch {}
357
+ history.replaceState(
358
+ { dataHref: new URL(model.dataHref, document.baseURI).href },
359
+ "",
360
+ );
361
+ const onPop = (event) => {
362
+ if (event.state?.dataHref)
363
+ openFolder(event.state.dataHref, location.href, false);
364
+ else location.reload();
365
+ };
366
+ window.addEventListener("popstate", onPop);
367
+ hydrateData();
368
+ return () => {
369
+ abort?.abort();
370
+ archiveAbort?.abort();
371
+ window.removeEventListener("popstate", onPop);
372
+ };
373
+ });
374
+ $effect(() => {
375
+ const href = updatesHref;
376
+ if (!href || typeof EventSource === "undefined") return;
377
+ const updates = new EventSource(href);
378
+ updates.onmessage = () => (pendingUpdates = href);
379
+ updates.onerror = () =>
380
+ (error = "Live updates disconnected. Refresh to retry.");
381
+ updates.onopen = () => {
382
+ if (error.startsWith("Live updates")) error = "";
383
+ };
384
+ return () => updates.close();
385
+ });
386
+ $effect(() => {
387
+ if (ready && !busy && pendingUpdates) {
388
+ const href = pendingUpdates;
389
+ pendingUpdates = null;
390
+ if (href === updatesHref)
391
+ openFolder(model.dataHref, location.href, false, true);
392
+ }
393
+ });
394
+ $effect(() => {
395
+ if (ready) document.documentElement.dataset.theme = scheme;
396
+ });
397
+ $effect(() => {
398
+ if (timeDialog && timestamp && !timeDialog.open) timeDialog.showModal();
399
+ });
400
+ $effect(() => {
401
+ if (globalDialog && globalOpen && !globalDialog.open) {
402
+ globalDialog.showModal();
403
+ tick().then(() => globalInput?.focus());
404
+ }
405
+ });
406
+ </script>
407
+
408
+ <svelte:window
409
+ onkeydown={keyboard}
410
+ onscroll={() => {
411
+ if (list) scrollTop = Math.max(0, window.scrollY - list.offsetTop);
412
+ }}
413
+ />
414
+ <header class="page-header">
415
+ <div class="wordmark">
416
+ {#if header}{@render header({ model })}{:else}<h1>{model.title}</h1>
417
+ <span>File archive</span>{/if}
418
+ </div>
419
+ <nav aria-label="Breadcrumb">
420
+ <a
421
+ href={model.rootHref}
422
+ onclick={(event) =>
423
+ navigateLink(event, model.rootHref, model.rootDataHref)}>Home</a
424
+ >{#each model.breadcrumbs as crumb}
425
+ / <a
426
+ href={crumb.href}
427
+ onclick={(event) => navigateLink(event, crumb.href, crumb.dataHref)}
428
+ >{crumb.name}</a
429
+ >{/each}
430
+ </nav>
431
+ </header>
432
+ <main data-explorer data-framework="svelte" aria-busy={busy}>
433
+ <div class="toolbar">
434
+ <label class="search-field"
435
+ >Search this directory<input
436
+ bind:this={searchInput}
437
+ type="search"
438
+ placeholder="Search files"
439
+ bind:value={query}
440
+ disabled={!ready || !complete}
441
+ oncompositionstart={() => (composing = true)}
442
+ oncompositionend={() => (composing = false)}
443
+ /></label
444
+ >
445
+ <fieldset disabled={!ready || !complete}>
446
+ <legend>Types</legend>{#each ["directory", "file", "link"] as type}<label
447
+ ><input
448
+ type="checkbox"
449
+ checked={types.includes(type)}
450
+ onchange={(event) =>
451
+ (types = event.currentTarget.checked
452
+ ? [...types, type]
453
+ : types.filter((value) => value !== type))}
454
+ />{type}</label
455
+ >{/each}
456
+ </fieldset>
457
+ <label
458
+ >Sort<select
459
+ value={sort.field}
460
+ disabled={!ready || !complete}
461
+ onchange={(event) =>
462
+ (sort = { ...sort, field: event.currentTarget.value })}
463
+ ><option value="name">Name</option><option value="size">Size</option
464
+ ><option value="modified">Modified</option></select
465
+ ></label
466
+ >
467
+ <label
468
+ >Names<select
469
+ value={sort.nameMode}
470
+ disabled={!ready || !complete}
471
+ onchange={(event) =>
472
+ (sort = { ...sort, nameMode: event.currentTarget.value })}
473
+ ><option value="natural">Natural</option><option value="locale"
474
+ >Locale</option
475
+ ><option value="unicode">Unicode</option></select
476
+ ></label
477
+ >
478
+ <label
479
+ >Order<select
480
+ value={sort.direction}
481
+ disabled={!ready || !complete}
482
+ onchange={(event) =>
483
+ (sort = { ...sort, direction: event.currentTarget.value })}
484
+ ><option value="asc">Ascending</option><option value="desc"
485
+ >Descending</option
486
+ ></select
487
+ ></label
488
+ >
489
+ <label
490
+ ><input
491
+ type="checkbox"
492
+ checked={sort.directoriesFirst}
493
+ disabled={!ready || !complete}
494
+ onchange={(event) =>
495
+ (sort = { ...sort, directoriesFirst: event.currentTarget.checked })}
496
+ />Folders first</label
497
+ >
498
+ <label
499
+ >Appearance<select
500
+ value={scheme}
501
+ disabled={!ready}
502
+ onchange={(event) => updateScheme(event.currentTarget.value)}
503
+ ><option value="system">System</option><option value="light"
504
+ >Light</option
505
+ ><option value="dark">Dark</option></select
506
+ ></label
507
+ >
508
+ <button
509
+ disabled={!ready || !model.searchIndexHref}
510
+ onclick={() => (globalOpen = true)}>Search archive</button
511
+ >
512
+ </div>
513
+ <section class="listing-card">
514
+ <div class="listing-summary">
515
+ <span data-visible-count>{complete ? rows.length : entries.length}</span>
516
+ entries{#if !complete}
517
+ on this page · {model.total} in directory{/if}<span
518
+ role="status"
519
+ aria-live="polite">{busy ? "Loading…" : error}</span
520
+ >
521
+ </div>
522
+ <div class="entry-head">
523
+ <span>Name</span><span>Size</span><span>Modified</span>
524
+ </div>
525
+ <ul class="entry-list" bind:this={list}>
526
+ {#if model.parentHref}<li class="entry">
527
+ <a
528
+ href={model.parentHref}
529
+ onclick={(event) =>
530
+ navigateLink(event, model.parentHref, model.parentDataHref)}
531
+ >../</a
532
+ >
533
+ </li>{/if}
534
+ {#if start}<li
535
+ aria-hidden="true"
536
+ style:height={offsets[start] + "px"}
537
+ ></li>{/if}
538
+ {#each rows.slice(start, end) as entry, index (entry.id)}
539
+ <li
540
+ class="entry"
541
+ use:measure={entry.id}
542
+ data-entry
543
+ data-id={entry.id}
544
+ data-order={start + index}
545
+ data-active={selected === entry.id}
546
+ >
547
+ <span class="identity"
548
+ >{#if entry.href}<a
549
+ class="name"
550
+ href={safeHref(entry)}
551
+ target={entry.exitsExplorer ? "_blank" : undefined}
552
+ rel={entry.exitsExplorer ? "noopener" : undefined}
553
+ onclick={(event) => navigate(event, entry)}
554
+ onfocus={() => (selected = entry.id)}
555
+ >{#if row}{@render row({
556
+ model,
557
+ entry,
558
+ selected: selected === entry.id,
559
+ })}{:else}<span class="file-icon" aria-hidden="true"
560
+ >{@html entryIcon(entryType(entry))}</span
561
+ >
562
+ {entry.name}{isDirectory(entry) ? "/" : ""}{/if}</a
563
+ >{:else}<span
564
+ >{#if row}{@render row({
565
+ model,
566
+ entry,
567
+ selected: selected === entry.id,
568
+ })}{:else}{entry.name}{/if}</span
569
+ >{/if}{#if entry.link}<small class="target"
570
+ >{entry.link.unavailable ? "Target unavailable · " : "→ "}{entry
571
+ .link.target}</small
572
+ >{/if}</span
573
+ >
574
+ <span class="kind"
575
+ >{isDirectory(entry) ? "Folder" : size(entry.size)}</span
576
+ >
577
+ <button
578
+ class="timestamp-button"
579
+ disabled={!ready}
580
+ onclick={() => (timestamp = entry.modifiedAt)}
581
+ ><time datetime={entry.modifiedAt}
582
+ >{modified(entry.modifiedAt, ready)}</time
583
+ ></button
584
+ >
585
+ </li>
586
+ {/each}
587
+ {#if end < rows.length}<li
588
+ aria-hidden="true"
589
+ style:height={offsets[rows.length] - offsets[end] + "px"}
590
+ ></li>{/if}
591
+ </ul>
592
+ {#if rows.length === 0}<p>No matching entries.</p>{/if}
593
+ </section>
594
+ {#if model.page?.count > 1 && !complete}<nav aria-label="Pagination">
595
+ {#if model.page.previousHref}<a rel="prev" href={model.page.previousHref}
596
+ >Previous</a
597
+ >{/if} Page {model.page.number} of {model.page.count}
598
+ {#if model.page.nextHref}<a rel="next" href={model.page.nextHref}>Next</a
599
+ >{/if}
600
+ </nav>{/if}
601
+ {#if footer}{@render footer({ model })}{:else}<p class="keyboard-help">
602
+ Arrow keys select files · Home / End jump · / searches · Backspace opens
603
+ parent
604
+ </p>{/if}
605
+ </main>
606
+ {#if timestamp}<dialog
607
+ bind:this={timeDialog}
608
+ onclose={() => (timestamp = null)}
609
+ >
610
+ <h2>Modified time</h2>
611
+ <p>{timestamp}</p>
612
+ <p>{new Date(timestamp).toLocaleString()}</p>
613
+ <button onclick={() => (timestamp = null)}>Close</button>
614
+ </dialog>{/if}
615
+ {#if globalOpen}<dialog
616
+ bind:this={globalDialog}
617
+ onclose={() => (globalOpen = false)}
618
+ >
619
+ <h2>Search archive</h2>
620
+ <input
621
+ aria-label="Search archive files"
622
+ bind:this={globalInput}
623
+ type="search"
624
+ bind:value={globalQuery}
625
+ oninput={searchArchive}
626
+ />
627
+ <p role="status">{globalStatus}</p>
628
+ <ul>
629
+ {#each globalRows as record}<li>
630
+ {#if record.href}<a
631
+ href={record.href}
632
+ target={record.exitsExplorer ? "_blank" : undefined}
633
+ rel={record.exitsExplorer ? "noopener" : undefined}
634
+ >{record.path}</a
635
+ >{:else}{record.path}{/if}
636
+ </li>{/each}
637
+ </ul>
638
+ <button onclick={() => (globalOpen = false)}>Close</button>
639
+ </dialog>{/if}
@@ -0,0 +1,4 @@
1
+ import type {Component, Snippet} from 'svelte';
2
+ import type {ThemePage, RowContext} from '../model.mjs';
3
+ export interface LedgerProps {readonly model: ThemePage; readonly header?: Snippet<[{model: ThemePage}]>; readonly row?: Snippet<[RowContext]>; readonly footer?: Snippet<[{model: ThemePage}]>;}
4
+ declare const Ledger: Component<LedgerProps>; export default Ledger;
@@ -0,0 +1,12 @@
1
+ // ../shared-theme/src/icons.ts
2
+ var paths = {
3
+ directory: '<path d="M3 7V5h6l2 3h10v12H3Z"/><path d="M3 10h18"/>',
4
+ file: '<path d="M5 3h9l5 5v13H5ZM14 3v5h5M8 12h8M8 16h6"/>',
5
+ link: '<path d="m9 15 6-6M8 10l-3 3a4 4 0 0 0 6 6l3-3M10 8l3-3a4 4 0 0 1 6 6l-3 3"/>'
6
+ };
7
+ function entryIcon(kind) {
8
+ return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.45" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${paths[kind]}</svg>`;
9
+ }
10
+ export {
11
+ entryIcon
12
+ };