diff --git a/.changeset/breezy-emus-smile.md b/.changeset/breezy-emus-smile.md new file mode 100644 index 0000000..c120812 --- /dev/null +++ b/.changeset/breezy-emus-smile.md @@ -0,0 +1,5 @@ +--- +'intrepid-ibex': minor +--- + +gnome browser/epiphany for lexicon schema browsing diff --git a/TODO.md b/TODO.md index d87eb7e..82d2f72 100644 --- a/TODO.md +++ b/TODO.md @@ -85,13 +85,13 @@ Every new surface should feel like a GNOME 2 app that happens to speak `at://`. - [ ] **Network Servers** - Inspect public PDS host info, version, available domains, and repo list. - Link repos into Nautilus and open server firehose views in System Monitor. -- [ ] **Web Browser** +- [x] **Web Browser** - GNOME-style browser for reading Lexicon schemas and resolution traces. - Accept NSIDs, `at://` lexicon records, and HTTPS URLs in an address bar. - Parse NSIDs into domain authority, authority domain, name segment, and optional fragment. - - For live lookup, resolve the NSID authority domain through DNS from the browser, - likely via DNS-over-HTTPS rather than a backend resolver. + - For live lookup, use the public lexdns API to resolve the NSID authority domain + through DNS-over-HTTPS. - Use the goat/Indigo Lexicon publication convention: read `did=...` TXT records from the NSID authority domain. - Show a resolution trace with each attempted DNS name, record type, answer, fetch URL, @@ -116,7 +116,7 @@ Every new surface should feel like a GNOME 2 app that happens to speak `at://`. - [ ] Add Archive Manager. - [ ] Add Eye of GNOME blob browsing and previews. - [ ] Add Label Browser. -- [ ] Add Web Browser for Lexicon viewing. +- [x] Add Web Browser for Lexicon viewing. - [ ] Add Log Viewer. - [ ] Add Network Servers. diff --git a/src/lib/atproto/lexicon.svelte.ts b/src/lib/atproto/lexicon.svelte.ts new file mode 100644 index 0000000..e7ec27e --- /dev/null +++ b/src/lib/atproto/lexicon.svelte.ts @@ -0,0 +1,102 @@ +import { normalizeLexiconInput, resolveLexicon, type LexiconTrace, type ResolvedLexicon } from './lexicon'; + +class LexiconBrowserState { + address = $state(''); + activeInput = $state(''); + result = $state(null); + error = $state(null); + errorCode = $state(null); + errorTrace = $state(null); + isLoading = $state(false); + history = $state([]); + historyIndex = $state(-1); + + get activeNsid() { + return this.result?.nsid ?? this.activeInput; + } + + get canGoBack() { + return this.historyIndex > 0; + } + + get canGoForward() { + return this.historyIndex >= 0 && this.historyIndex < this.history.length - 1; + } + + async open(input: string, options: { recordHistory?: boolean } = {}) { + const recordHistory = options.recordHistory ?? true; + const normalized = normalizeLexiconInput(input); + this.address = normalized; + this.activeInput = normalized; + this.isLoading = true; + this.error = null; + this.errorCode = null; + this.errorTrace = null; + + try { + const resolved = await resolveLexicon(normalized); + this.result = resolved; + this.address = resolved.nsid; + + if (recordHistory) this.remember(resolved.nsid); + } catch (unknownError) { + this.result = null; + + if (unknownError instanceof Error) { + const details = unknownError as Error & { code?: unknown; trace?: unknown }; + this.error = unknownError.message; + this.errorCode = details.code ? String(details.code) : 'lookup_error'; + this.errorTrace = details.trace ? (details.trace as LexiconTrace) : null; + } else { + this.error = String(unknownError); + this.errorCode = 'lookup_error'; + } + } finally { + this.isLoading = false; + } + } + + async reload() { + const target = this.activeNsid || this.address; + if (!target) return; + + await this.open(target, { recordHistory: false }); + } + + async goBack() { + if (!this.canGoBack) return; + + this.historyIndex -= 1; + const target = this.history[this.historyIndex]; + if (target) await this.open(target, { recordHistory: false }); + } + + async goForward() { + if (!this.canGoForward) return; + + this.historyIndex += 1; + const target = this.history[this.historyIndex]; + if (target) await this.open(target, { recordHistory: false }); + } + + clear() { + this.address = ''; + this.activeInput = ''; + this.result = null; + this.error = null; + this.errorCode = null; + this.errorTrace = null; + this.isLoading = false; + } + + private remember(nsid: string) { + if (this.history[this.historyIndex] === nsid) return; + + const nextHistory = this.history.slice(0, this.historyIndex + 1); + nextHistory.push(nsid); + this.history = nextHistory.slice(-25); + this.historyIndex = this.history.length - 1; + } +} + +export const lexiconBrowser = new LexiconBrowserState(); diff --git a/src/lib/atproto/lexicon.test.ts b/src/lib/atproto/lexicon.test.ts new file mode 100644 index 0000000..041c5a4 --- /dev/null +++ b/src/lib/atproto/lexicon.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; +import { lexiconApiUrl, lexiconPath, normalizeLexiconInput } from './lexicon'; + +describe('lexicon helpers', () => { + it('normalizes NSID input', () => { + expect(normalizeLexiconInput(' app.bsky.feed.post ')).toBe('app.bsky.feed.post'); + }); + + it('normalizes at:// lexicon records', () => { + expect(normalizeLexiconInput('at://did:plc:abc/com.atproto.lexicon.schema/app.bsky.feed.post#main')).toBe( + 'app.bsky.feed.post' + ); + }); + + it('normalizes lexdns and app route URLs', () => { + expect(normalizeLexiconInput('https://lex.desertthunder.dev/api/resolve/com.atproto.repo.getRecord')).toBe( + 'com.atproto.repo.getRecord' + ); + expect(normalizeLexiconInput('https://ibex.example/lexicons/app.bsky.actor.profile')).toBe( + 'app.bsky.actor.profile' + ); + }); + + it('builds lexdns and app URLs', () => { + expect(lexiconApiUrl('app.bsky.feed.post')).toBe('https://lex.desertthunder.dev/api/resolve/app.bsky.feed.post'); + expect(lexiconPath('app.bsky.feed.post')).toBe('/lexicons/app.bsky.feed.post'); + }); +}); diff --git a/src/lib/atproto/lexicon.ts b/src/lib/atproto/lexicon.ts new file mode 100644 index 0000000..c2cb09d --- /dev/null +++ b/src/lib/atproto/lexicon.ts @@ -0,0 +1,163 @@ +export const LEXDNS_API_BASE = 'https://lex.desertthunder.dev/api/resolve'; + +export type ParsedLexiconNsid = { nsid: string; authority: string; domain: string; name: string; dnsName: string }; + +export type LexiconDocument = { + $type?: string; + lexicon: 1; + id: string; + defs: Record; + [key: string]: unknown; +}; + +export type LexiconDefinition = { + type?: string; + description?: string; + key?: string; + required?: string[]; + record?: LexiconObject; + parameters?: LexiconObject; + input?: LexiconEncoding; + output?: LexiconEncoding; + properties?: Record; + refs?: string[]; + ref?: string; + knownValues?: string[]; + [key: string]: unknown; +}; + +export type LexiconObject = { + type?: string; + required?: string[]; + properties?: Record; + [key: string]: unknown; +}; + +export type LexiconEncoding = { encoding?: string; schema?: LexiconObject; [key: string]: unknown }; + +export type LexiconProperty = { + type?: string; + description?: string; + format?: string; + ref?: string; + refs?: string[]; + items?: LexiconProperty; + knownValues?: string[]; + required?: string[]; + properties?: Record; + [key: string]: unknown; +}; + +export type LexiconTrace = { + dnsName: string | null; + txtRecords: Array<{ name: string; data: string; ttl?: number }>; + selectedDid: string | null; + didDocument: { url: string | null; status: number | null }; + pdsEndpoint: string | null; + repoGetRecord: { url: string | null; status: number | null }; + final: { success: boolean; code?: string; message: string }; +}; + +export type ResolvedLexicon = { + version: string; + nsid: string; + parsed: ParsedLexiconNsid; + hash: string; + fetchedAt: string; + source: { name: string; url: string }; + lexicon: LexiconDocument; + cache: 'hit' | 'miss'; + trace: LexiconTrace; + _links: { self: { href: string }; source: { href: string } }; +}; + +export type LexiconApiError = { version?: string; error: { code: string; message: string }; trace?: LexiconTrace }; + +export class LexiconResolveError extends Error { + constructor( + readonly status: number, + readonly code: string, + message: string, + readonly trace: LexiconTrace | null + ) { + super(message); + this.name = 'LexiconResolveError'; + } +} + +export async function resolveLexicon(input: string, fetcher: typeof fetch = fetch): Promise { + const nsid = normalizeLexiconInput(input); + const response = await fetcher(lexiconApiUrl(nsid), { headers: { accept: 'application/json' } }); + const body = (await response.json()) as ResolvedLexicon | LexiconApiError; + + if (!response.ok || isLexiconApiError(body)) { + const apiError = isLexiconApiError(body) + ? body.error + : { code: 'upstream_error', message: `Lexicon lookup failed with ${response.status}` }; + throw new LexiconResolveError(response.status, apiError.code, apiError.message, body.trace ?? null); + } + + return body; +} + +export function lexiconApiUrl(nsid: string) { + return `${LEXDNS_API_BASE}/${encodeURIComponent(nsid)}`; +} + +export function lexiconPath(input: string) { + return `/lexicons/${encodeURIComponent(normalizeLexiconInput(input))}`; +} + +export function normalizeLexiconInput(input: string) { + const trimmed = input.trim(); + if (!trimmed) throw new Error('Enter an NSID or Lexicon record URL.'); + + const withoutHash = trimmed.split('#', 1)[0] ?? trimmed; + + if (withoutHash.startsWith('at://')) { + const nsid = nsidFromAtUri(withoutHash); + if (nsid) return nsid; + } + + if (/^https?:\/\//i.test(withoutHash)) { + const nsid = nsidFromUrl(withoutHash); + if (nsid) return nsid; + } + + const nsid = withoutHash.replace(/^\/lexicons\//, ''); + return decodeURIComponent(nsid); +} + +function nsidFromAtUri(input: string) { + const withoutProtocol = input.slice('at://'.length); + const [, collection, rkey] = withoutProtocol.split('/'); + if (collection === 'com.atproto.lexicon.schema' && rkey) return decodeURIComponent(rkey); + return null; +} + +function nsidFromUrl(input: string) { + try { + const url = new URL(input); + const apiMatch = url.pathname.match(/\/api\/resolve\/([^/]+)$/); + if (apiMatch?.[1]) return decodeURIComponent(apiMatch[1]); + + const routeMatch = url.pathname.match(/\/lexicons\/([^/]+)$/); + if (routeMatch?.[1]) return decodeURIComponent(routeMatch[1]); + + const queryNsid = url.searchParams.get('nsid'); + if (queryNsid) return queryNsid; + } catch { + return null; + } + + return null; +} + +function isLexiconApiError(value: unknown): value is LexiconApiError { + return ( + typeof value === 'object' && + value !== null && + 'error' in value && + typeof (value as LexiconApiError).error?.message === 'string' + ); +} diff --git a/src/lib/atproto/routes.ts b/src/lib/atproto/routes.ts index e1deabb..dd1335e 100644 --- a/src/lib/atproto/routes.ts +++ b/src/lib/atproto/routes.ts @@ -6,6 +6,8 @@ export const identityPath = (did: string) => `${repoPath(did)}/identity`; export const blobsPath = (did: string) => `${repoPath(did)}/blobs`; +export const lexiconsPath = () => '/lexicons'; + export function blobPath(did: string, cid: string) { return `${blobsPath(did)}/${encodeURIComponent(cid)}`; } diff --git a/src/lib/components/CollectionBrowser.svelte b/src/lib/components/CollectionBrowser.svelte index 3d5eaf9..3753253 100644 --- a/src/lib/components/CollectionBrowser.svelte +++ b/src/lib/components/CollectionBrowser.svelte @@ -7,6 +7,7 @@ import { accountSetup } from '$lib/atproto/setup.svelte'; import { repoBrowser } from '$lib/atproto/repo.svelte'; import { collectionPath, identityPath, recordPath } from '$lib/atproto/routes'; + import { lexiconPath } from '$lib/atproto/lexicon'; import { isRecordValue } from '$lib/atproto/types'; import type { CollectionSummary, RepoRecordSummary } from '$lib/atproto/types'; import { SvelteMap } from 'svelte/reactivity'; @@ -17,7 +18,6 @@ let collectionFilter = $state(''); let reverseRecords = $state(false); let previewField = $state('summary'); - const filteredCollections = $derived.by(() => { const query = collectionFilter.trim().toLowerCase(); if (!query) return repoBrowser.collections; @@ -27,11 +27,13 @@ return collection.name.toLowerCase().includes(query) || label.includes(query); }); }); + const collectionGroups = $derived.by(() => groupCollections(filteredCollections)); const visibleRecords = $derived.by(() => { if (!reverseRecords) return repoBrowser.records; return [...repoBrowser.records].reverse(); }); + const identity = $derived(repoSession.identity ?? (page.route.id === '/browse' ? accountSetup.identity : null)); const canUseAsDefault = $derived(Boolean(identity && identity.did !== accountSetup.identity?.did)); type RepoPathname = `/repos/${string}`; @@ -64,7 +66,6 @@ function openRecord(record: RepoRecordSummary) { repoBrowser.selectedRecord = record; - if (identity) { navigate(recordPath({ did: identity.did, collection: record.collection, rkey: record.rkey })); } @@ -72,25 +73,30 @@ function openIdentityInspector() { if (!identity) return; - navigate(identityPath(identity.did)); } + function openCollectionSchema() { + if (!repoBrowser.selectedCollection) return; + void navigateTo(resolve(lexiconPath(repoBrowser.selectedCollection) as `/lexicons/${string}`), { + keepFocus: true, + noScroll: true + }); + } + function updatePageSize(event: Event) { const select = event.currentTarget; - if (!(select instanceof HTMLSelectElement) || !identity) return; - void repoBrowser.setRecordPageSize(identity, Number(select.value)); } function updatePreviewField(event: Event) { const select = event.currentTarget; if (!(select instanceof HTMLSelectElement)) return; - previewField = select.value as PreviewField; } + // TODO: change to switch...case function previewTitle(record: RepoRecordSummary) { if (previewField === 'summary') return record.title; if (previewField === 'text') return stringRecordField(record, 'text') ?? record.title; @@ -98,10 +104,10 @@ if (previewField === 'createdAt') return stringRecordField(record, 'createdAt') ?? record.modified; if (previewField === 'uri') return record.uri; if (previewField === 'cid') return record.cid || 'No CID'; - return record.title; } + // TODO: change to switch...case function previewBody(record: RepoRecordSummary) { if (previewField === 'summary') return record.body; if (previewField === 'text') return record.body; @@ -109,13 +115,11 @@ if (previewField === 'createdAt') return `Indexed as ${record.modified}`; if (previewField === 'uri') return `${record.collection}/${record.rkey}`; if (previewField === 'cid') return record.cid ? `Record CID: ${record.cid}` : 'Record returned without a CID.'; - return record.body; } function stringRecordField(record: RepoRecordSummary, key: string) { if (!isRecordValue(record.value)) return null; - const value = record.value[key]; return typeof value === 'string' && value.length > 0 ? value : null; } @@ -284,6 +288,14 @@ Identity + {#if canUseAsDefault} {/each} diff --git a/src/lib/components/browser/BrowserSummary.svelte b/src/lib/components/browser/BrowserSummary.svelte new file mode 100644 index 0000000..426eecf --- /dev/null +++ b/src/lib/components/browser/BrowserSummary.svelte @@ -0,0 +1,116 @@ + + + + + diff --git a/src/lib/components/browser/BrowserTabs.svelte b/src/lib/components/browser/BrowserTabs.svelte new file mode 100644 index 0000000..c0e77f0 --- /dev/null +++ b/src/lib/components/browser/BrowserTabs.svelte @@ -0,0 +1,55 @@ + + + + + diff --git a/src/lib/components/browser/BrowserToolbar.svelte b/src/lib/components/browser/BrowserToolbar.svelte new file mode 100644 index 0000000..983d030 --- /dev/null +++ b/src/lib/components/browser/BrowserToolbar.svelte @@ -0,0 +1,153 @@ + + +
{ + event.preventDefault(); + onsubmit(); + }}> + + + + + + +
+ +
+
+ + diff --git a/src/lib/components/browser/SchemaDocument.svelte b/src/lib/components/browser/SchemaDocument.svelte new file mode 100644 index 0000000..904041f --- /dev/null +++ b/src/lib/components/browser/SchemaDocument.svelte @@ -0,0 +1,224 @@ + + +
+
+

Lexicon {lexicon.lexicon}

+

{lexicon.id}

+
+ + {#each schemaRows as def (def.name)} +
+
+
+

{def.type}

+

#{def.name}

+
+ {#if def.required.length > 0} + {def.required.length} required + {/if} +
+ + {#if def.description} +

{def.description}

+ {/if} + + {#if def.properties.length > 0} + + + + + + + + + + {#each def.properties as property (property.name)} + + + + + + {/each} + +
FieldTypeDescription
+ {property.name} + {#if def.required.includes(property.name)} + required + {/if} + {property.type}{property.description || 'No description'}
+ {/if} + + {#if def.refs.length > 0} +
+ References + {#each def.refs as ref (ref)} + {@const nsid = referenceNsid(ref)} + {#if nsid} + + {:else} + {ref} + {/if} + {/each} +
+ {/if} +
+ {/each} +
+ + diff --git a/src/lib/components/browser/TraceView.svelte b/src/lib/components/browser/TraceView.svelte new file mode 100644 index 0000000..42df229 --- /dev/null +++ b/src/lib/components/browser/TraceView.svelte @@ -0,0 +1,79 @@ + + +
+ {#each traceRows as row (row.step)} +
+

{row.step}

+
{row.value}
+
+ {:else} +
+

Trace

+
No resolution trace is available.
+
+ {/each} +
+ + diff --git a/src/lib/components/browser/schema.ts b/src/lib/components/browser/schema.ts new file mode 100644 index 0000000..071a78e --- /dev/null +++ b/src/lib/components/browser/schema.ts @@ -0,0 +1,71 @@ +import type { LexiconDefinition, LexiconProperty } from '$lib/atproto/lexicon'; + +export type LexiconDefRow = { + name: string; + type: string; + description: string; + required: string[]; + properties: Array<{ name: string; type: string; description: string }>; + refs: string[]; +}; + +export function defRows(defs: Record): LexiconDefRow[] { + return Object.entries(defs).map(([name, def]) => { + const object = def.record ?? def.parameters ?? def.input?.schema ?? def.output?.schema ?? def; + const properties = Object.entries(object.properties ?? {}).map(([propertyName, property]) => ({ + name: propertyName, + type: propertyType(property), + description: property.description ?? '' + })); + + return { + name, + type: def.type ?? object.type ?? 'definition', + description: def.description ?? '', + required: object.required ?? def.required ?? [], + properties, + refs: refsForDefinition(def) + }; + }); +} + +export function referenceNsid(ref: string) { + const [nsid] = ref.split('#'); + if (!nsid) return null; + if (nsid.startsWith('#')) return null; + if (!nsid.includes('.')) return null; + return nsid; +} + +function propertyType(property: LexiconProperty): string { + if (property.ref) return property.ref; + if (property.refs) return property.refs.join(' | '); + if (property.type === 'array' && property.items) return `array<${propertyType(property.items)}>`; + if (property.format) return `${property.type ?? 'value'}:${property.format}`; + return property.type ?? 'unknown'; +} + +function refsForDefinition(def: LexiconDefinition) { + const refs = new Set(); + collectRefs(def, refs); + return [...refs].sort(); +} + +function collectRefs(value: unknown, refs: Set) { + if (Array.isArray(value)) { + for (const item of value) collectRefs(item, refs); + return; + } + + if (typeof value !== 'object' || value === null) return; + + const record = value as Record; + if (typeof record.ref === 'string') refs.add(record.ref); + if (Array.isArray(record.refs)) { + for (const ref of record.refs) { + if (typeof ref === 'string') refs.add(ref); + } + } + + for (const child of Object.values(record)) collectRefs(child, refs); +} diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index c4e07da..583120d 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -45,6 +45,7 @@ if (page.route.id === '/') return 'Welcome to Intrepid Ibex'; // TODO: this should be slug aware if (page.route.id?.startsWith('/docs')) return 'Document Viewer'; + if (page.route.id?.startsWith('/lexicons')) return 'Web Browser'; return 'AT Protocol Collections - Intrepid Ibex'; }); @@ -52,6 +53,7 @@ if (routeRequiresSetup) return '/icons/humanity/places/user-home.svg'; if (page.route.id === '/') return '/icons/humanity/devices/computer.svg'; if (page.route.id?.startsWith('/docs')) return '/icons/humanity/mimes/gnome-mime-application-pdf.svg'; + if (page.route.id?.startsWith('/lexicons')) return '/icons/humanity/apps/web-browser.svg'; return '/icons/humanity/apps/internet-feed-reader.svg'; }); @@ -63,6 +65,11 @@ const identityInspectorWindow = $derived(windowManager.getWindow('identity-inspector')); const eogWindow = $derived(windowManager.getWindow('eog')); const repoIdentity = $derived(repoSession.identity ?? accountSetup.identity); + const trashShortcut = { + label: 'Trash', + icon: '/icons/humanity/places/user-trash.svg', + onactivate: () => window.open('https://github.com/desertthunder.dev/ibex', '_blank', 'noopener,noreferrer') + }; const shortcuts = $derived([ { label: 'ibex Home', @@ -110,6 +117,15 @@ void goto(resolve('/browse')); } }, + { + label: 'Web Browser', + icon: '/icons/humanity/apps/web-browser.svg', + selected: page.route.id?.startsWith('/lexicons'), + onactivate: () => { + windowManager.restore('main'); + void goto(resolve('/lexicons')); + } + }, { label: 'Computer', icon: '/icons/humanity/devices/computer.svg', @@ -127,11 +143,6 @@ windowManager.restore('main'); void goto(resolve('/docs')); } - }, - { - label: 'Trash', - icon: '/icons/humanity/places/user-trash.svg', - onactivate: () => window.open('https://github.com/desertthunder.dev/ibex', '_blank', 'noopener,noreferrer') } ]); @@ -241,7 +252,7 @@ } function closeMainWindow() { - if (page.route.id === '/browse' || page.route.id?.startsWith('/repos')) { + if (page.route.id === '/browse' || page.route.id?.startsWith('/repos') || page.route.id?.startsWith('/lexicons')) { void goto(resolve('/'), { keepFocus: true, noScroll: true }); } } @@ -278,6 +289,10 @@ {/each} +
+ +
+ {#if mainWindow?.isOpen && !mainWindow.isMinimized}
{#if routeUsesNativeWindow} @@ -451,6 +466,14 @@ gap: var(--space-4); } + .desktop-trash { + position: absolute; + top: var(--space-5); + right: var(--space-5); + display: grid; + justify-items: center; + } + .primary-window { position: relative; align-self: center; @@ -536,6 +559,11 @@ padding: var(--space-3); } + .desktop-trash { + top: var(--space-3); + right: var(--space-3); + } + .about-window, .gedit-window, .identity-inspector-window, @@ -554,6 +582,10 @@ display: none; } + .desktop-trash { + display: none; + } + .primary-window { height: calc(100vh - 4.25rem); min-height: 0; diff --git a/src/routes/lexicons/+layout.svelte b/src/routes/lexicons/+layout.svelte new file mode 100644 index 0000000..a61101b --- /dev/null +++ b/src/routes/lexicons/+layout.svelte @@ -0,0 +1,244 @@ + + +
+ + + (lexiconBrowser.address = address)} + onsubmit={submitAddress} + onback={() => void lexiconBrowser.goBack()} + onforward={() => void lexiconBrowser.goForward()} + onreload={() => void lexiconBrowser.reload()} + onhome={openHome} /> + +
+ + +
+ (activeTab = tab)} /> + +
+ {#if lexiconBrowser.isLoading} +
+ +

Resolving Lexicon schema...

+

{lexiconBrowser.address}

+
+ {:else if lexiconBrowser.error} +
+ +

{lexiconBrowser.errorCode ?? 'Lookup error'}

+

{lexiconBrowser.error}

+
+ {:else if !lexiconBrowser.result} +
+ +

No page loaded

+

Try app.bsky.feed.post or com.atproto.repo.getRecord.

+
+ {:else if activeTab === 'schema'} + + {:else if activeTab === 'json'} +
{rawJson}
+ {:else} + + {/if} +
+
+
+ +
+ {statusText} + {#if lexiconBrowser.result} + {lexiconBrowser.result.source.url} + {:else} + lex.desertthunder.dev + {/if} +
+
+ +{@render children()} + + diff --git a/src/routes/lexicons/+page.svelte b/src/routes/lexicons/+page.svelte new file mode 100644 index 0000000..e69de29 diff --git a/src/routes/lexicons/[nsid]/+page.svelte b/src/routes/lexicons/[nsid]/+page.svelte new file mode 100644 index 0000000..e69de29