From d8e697a14a8515ca83e1f9f4e5d6026a3b755cd7 Mon Sep 17 00:00:00 2001 From: Owais Jamil Date: Thu, 18 Jun 2026 04:27:07 +0000 Subject: [PATCH] feat: favicon and partials --- worker-configuration.d.ts | 4 ++++ wrangler.jsonc | 2 +- src/http.ts | 7 +++++++ src/main.ts | 31 ++++++++++++++++++++++++------- src/view.ts | 32 +++++++++++++++++++++++++++++--- test/view.test.ts | 7 +++++++ src/view/favicon.svg | 5 +++++ src/view/partials.ts | 17 ++++++++++++++--- src/view/templates/index.mustache | 96 +++--------------------------------------------------------------------------------------------- src/view/styles/components/resolver.css | 81 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/view/templates/partials/footer.mustache | 21 +++++++++++++++++++++ src/view/templates/partials/head.mustache | 28 ++++++++++++++++++++++++++++ src/view/templates/partials/hero.mustache | 5 +++++ src/view/templates/partials/resolver.mustache | 69 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/view/templates/partials/topbar.mustache | 9 +++++++++ 15 file(s) changed, 307 insertion(s)(+), 107 deletion(s)(-) diff --git a/worker-configuration.d.ts b/worker-configuration.d.ts --- a/worker-configuration.d.ts +++ b/worker-configuration.d.ts @@ -27,6 +27,10 @@ const value: string; export default value; } +declare module '*.svg' { + const value: string; + export default value; +} declare module '*.txt' { const value: string; export default value; diff --git a/wrangler.jsonc b/wrangler.jsonc --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -4,7 +4,7 @@ "main": "src/main.ts", "compatibility_date": "2026-06-18", "compatibility_flags": ["nodejs_compat"], - "rules": [{ "type": "Text", "globs": ["**/*.mustache", "**/*.css", "**/*.txt"], "fallthrough": true }], + "rules": [{ "type": "Text", "globs": ["**/*.mustache", "**/*.css", "**/*.svg", "**/*.txt"], "fallthrough": true }], "observability": { "enabled": true }, "vars": { "CACHE_TTL_SECONDS": "86400", "DOH_ENDPOINT": "https://cloudflare-dns.com/dns-query" }, "kv_namespaces": [ diff --git a/src/http.ts b/src/http.ts --- a/src/http.ts +++ b/src/http.ts @@ -89,6 +89,13 @@ return new Response(body, { ...init, headers }); } +export function svgResponse(body: string, init: ResponseInit = {}): Response { + const headers = new Headers(init.headers); + headers.set('content-type', 'image/svg+xml; charset=utf-8'); + + return new Response(body, { ...init, headers }); +} + export function apiError(status: number, code: ApiErrorCode, message: string): Response { return jsonResponse({ error: { code, message } }, { status }); } diff --git a/src/main.ts b/src/main.ts --- a/src/main.ts +++ b/src/main.ts @@ -1,12 +1,28 @@ import { Hono } from 'hono'; -import { apiError, htmlResponse } from './http'; +import { apiError, htmlResponse, jsonResponse } from './http'; import { mapResolveError, resolveLexicon, resolvePathNsid, suggestNsids } from './lexicon'; import { assetResponse, renderIndex, renderJsonPartial, renderSuggestionsPartial } from './view'; const app = new Hono<{ Bindings: Env }>(); +const publicApiCorsHeaders = { + 'access-control-allow-origin': '*', + 'access-control-allow-methods': 'GET, OPTIONS', + 'access-control-allow-headers': 'Accept, Content-Type', + 'access-control-max-age': '86400' +}; + app.get('/', (c) => { return renderIndex(new URL(c.req.url).origin); +}); + +app.get('/favicon.svg', () => { + const response = assetResponse('/favicon.svg'); + if (response !== null) { + return response; + } + + return apiError(404, 'not_found', 'Not found'); }); app.get('/styles/*', (c) => { @@ -43,20 +59,21 @@ return htmlResponse(renderSuggestionsPartial(suggestions)); }); -app.get('/api/suggest', async (c) => { - const query = c.req.query('q') ?? ''; - const suggestions = await suggestNsids(query, c.env); - return c.json({ query, items: suggestions }); +app.options('/api/resolve/*', () => { + return new Response(null, { status: 204, headers: publicApiCorsHeaders }); }); app.get('/api/resolve/*', async (c) => { try { const nsid = resolvePathNsid(new URL(c.req.url).pathname); const resolved = await resolveLexicon(nsid, c.env); - return c.json(resolved); + return jsonResponse(resolved, { headers: publicApiCorsHeaders }); } catch (error) { const mapped = mapResolveError(error); - return apiError(mapped.status, mapped.code, mapped.message); + return jsonResponse( + { error: { code: mapped.code, message: mapped.message } }, + { status: mapped.status, headers: publicApiCorsHeaders } + ); } }); diff --git a/src/view.ts b/src/view.ts --- a/src/view.ts +++ b/src/view.ts @@ -1,6 +1,12 @@ import Mustache from 'mustache'; -import { cssResponse, htmlResponse, scriptResponse } from './http'; +import { cssResponse, htmlResponse, scriptResponse, svgResponse } from './http'; import indexTemplate from './view/templates/index.mustache'; +import footerTemplate from './view/templates/partials/footer.mustache'; +import headTemplate from './view/templates/partials/head.mustache'; +import heroTemplate from './view/templates/partials/hero.mustache'; +import resolverTemplate from './view/templates/partials/resolver.mustache'; +import topbarTemplate from './view/templates/partials/topbar.mustache'; +import faviconSvg from './view/favicon.svg'; import styleCss from './view/styles/style.css'; import resetCss from './view/styles/reset.css'; import tokensCss from './view/styles/tokens.css'; @@ -23,7 +29,7 @@ heading: 'Links', items: [ { label: 'Author', value: 'Owais', href: 'https://desertthunder.dev' }, - { label: 'Repo', value: 'lexdns', href: 'https://github.com/desertthunder/lexdns' } + { label: 'Repo', value: 'Github', href: 'https://github.com/desertthunder/lexdns' } ] } } as const satisfies FooterConfig; @@ -46,6 +52,16 @@ ['/vendor/prism-json.min.js', prismJsonJs] ]); +const images = new Map([['/favicon.svg', faviconSvg]]); + +const indexPartials = { + footer: footerTemplate, + head: headTemplate, + hero: heroTemplate, + resolver: resolverTemplate, + topbar: topbarTemplate +} as const; + function cacheHeader(pathname: string): string { if (pathname.startsWith('/vendor/')) { return 'public, max-age=31536000, immutable'; @@ -55,11 +71,21 @@ } export function renderIndex(baseUrl: string): Response { - const body = Mustache.render(indexTemplate, { title: 'lexdns', baseUrl, footer: footerConfig }); + const description = 'Resolve ATProto Lexicon schemas from NSIDs through DNS TXT discovery.'; + const body = Mustache.render( + indexTemplate, + { title: 'lexdns | Lexicon REST API', description, baseUrl, canonicalUrl: `${baseUrl}/`, footer: footerConfig }, + indexPartials + ); return htmlResponse(body); } export function assetResponse(pathname: string): Response | null { + const svg = images.get(pathname); + if (svg !== undefined) { + return svgResponse(svg, { headers: { 'cache-control': cacheHeader(pathname) } }); + } + const stylesheet = stylesheets.get(pathname); if (stylesheet !== undefined) { return cssResponse(stylesheet, { headers: { 'cache-control': cacheHeader(pathname) } }); diff --git a/test/view.test.ts b/test/view.test.ts --- a/test/view.test.ts +++ b/test/view.test.ts @@ -20,6 +20,13 @@ expect(html).not.toContain('Lexicon: - - - - + {{> head}}
-
- -
- -
-

lex.desertthunder.dev

-

Lex

-

Lexicon Schema REST API

-
- -
-
-

Resolve a schema

-

Enter an NSID and the resolver will look for its published DNS TXT record.

-
- -
- -
-
- - -
-
-
-
- -
- -
- curl - curl {{baseUrl}}/api/resolve/com.example.foo -
-
+ {{> topbar}} {{> hero}} {{> resolver}}
-
- - - -
+ {{> footer}}
diff --git a/src/view/styles/components/resolver.css b/src/view/styles/components/resolver.css --- a/src/view/styles/components/resolver.css +++ b/src/view/styles/components/resolver.css @@ -18,6 +18,7 @@ .resolver-form__field { display: grid; gap: var(--space-2); + position: relative; } .resolver-form input { @@ -37,12 +38,39 @@ cursor: pointer; } +.resolver-form button:disabled { + cursor: wait; + opacity: 0.72; +} + .resolver-form button:hover { filter: brightness(0.95); } +[x-cloak] { + display: none !important; +} + +.htmx-indicator { + visibility: hidden; + opacity: 0; +} + +.htmx-request .htmx-indicator, +.htmx-request.htmx-indicator { + visibility: visible; + opacity: 1; +} + .resolver-form__suggestions:empty { display: none; +} + +.resolver-form__suggestions { + position: absolute; + z-index: 10; + inset-block-start: calc(100% + var(--space-2)); + inset-inline: 0; } .nsid-suggestions { @@ -104,6 +132,49 @@ max-inline-size: 100%; } +.resolver-state { + display: flex; + align-items: center; + gap: var(--space-3); + padding: var(--space-3) var(--space-4); + border-radius: var(--radius-sm); + font-size: var(--size-sm); + font-weight: 600; +} + +.resolver-state--loading { + color: var(--muted); + background: var(--surface-subtle); + border: 1px solid var(--border); +} + +.resolver-state--error { + color: #8f1d1d; + background: #fff1f1; + border: 1px solid #f0b7b7; +} + +body[data-theme='dark'] .resolver-state--error { + color: #ffd3d3; + background: #2a1416; + border-color: #673034; +} + +.resolver-state__spinner { + inline-size: 0.9rem; + block-size: 0.9rem; + border: 2px solid currentColor; + border-inline-end-color: transparent; + border-radius: 999px; + animation: resolver-spin 700ms linear infinite; +} + +@keyframes resolver-spin { + to { + transform: rotate(1turn); + } +} + .json-result { display: grid; min-inline-size: 0; @@ -125,6 +196,16 @@ border-block-end: 1px solid #26313a; font-size: var(--size-sm); font-weight: 600; +} + +.json-result--error { + border-color: #8f3636; +} + +.json-result--error .json-result__bar { + color: #ffe2e2; + background: #4a171b; + border-block-end-color: #8f3636; } .json-result__bar button { diff --git a/src/view/templates/partials/footer.mustache b/src/view/templates/partials/footer.mustache new file mode 100644 --- /dev/null +++ b/src/view/templates/partials/footer.mustache @@ -0,0 +1,21 @@ +
+ + + +
diff --git a/src/view/templates/partials/head.mustache b/src/view/templates/partials/head.mustache new file mode 100644 --- /dev/null +++ b/src/view/templates/partials/head.mustache @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + {{title}} + + + + + + + + + + + + diff --git a/src/view/templates/partials/hero.mustache b/src/view/templates/partials/hero.mustache new file mode 100644 --- /dev/null +++ b/src/view/templates/partials/hero.mustache @@ -0,0 +1,5 @@ +
+

lex.desertthunder.dev

+

Lex

+

Lexicon Schema REST API

+
diff --git a/src/view/templates/partials/resolver.mustache b/src/view/templates/partials/resolver.mustache new file mode 100644 --- /dev/null +++ b/src/view/templates/partials/resolver.mustache @@ -0,0 +1,69 @@ +
+
+

Resolve a schema

+

Enter an NSID and the resolver will look for its published DNS TXT record.

+
+ +
+ +
+
+ + +
+
+
+
+ +
+ + Resolving schema +
+ +
+ Request failed. Try again. +
+ +
+ +
+ curl + curl {{{baseUrl}}}/api/resolve/com.example.foo +
+
diff --git a/src/view/templates/partials/topbar.mustache b/src/view/templates/partials/topbar.mustache new file mode 100644 --- /dev/null +++ b/src/view/templates/partials/topbar.mustache @@ -0,0 +1,9 @@ +
+ +
-- tangled.sh