From 59795a4a5ccb90110da0de060d4b478b5be389fa Mon Sep 17 00:00:00 2001 From: JP Hastings-Spital Date: Sat, 27 Jun 2026 22:34:00 +0200 Subject: [PATCH] Render lexicon docs from the source-of-truth lexicons The About page embedded a hand-copied snapshot of at.margin.note. This exposes every lexicon in ./lexicons via a virtual:lexicons module (keyed by NSID id, read raw at build time) and have About load at.margin.note from it, so the docs can no longer fall out of sync with the schema. The web Docker stage now copies lexicons/ so the build can read them. --- Dockerfile | 2 + web/astro.config.mjs | 38 ++++++- web/src/env.d.ts | 5 + web/src/views/About.tsx | 237 +--------------------------------------- 4 files changed, 46 insertions(+), 236 deletions(-) diff --git a/Dockerfile b/Dockerfile index e1a4c0f..777dc8a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,6 +4,8 @@ WORKDIR /app/web COPY web/package.json web/bun.lock ./ RUN bun install COPY web/ ./ +# Lexicons are the source of truth for schema docs rendered by the web build. +COPY lexicons/ /app/lexicons/ RUN bun run build FROM golang:1.25-alpine AS backend-builder diff --git a/web/astro.config.mjs b/web/astro.config.mjs index 26d2437..acba882 100644 --- a/web/astro.config.mjs +++ b/web/astro.config.mjs @@ -11,6 +11,42 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); const API_PORT = process.env.API_PORT || 8081; +function lexiconsPlugin() { + const virtualId = "virtual:lexicons"; + const resolvedId = "\0" + virtualId; + return { + name: "lexicons", + /** @param {string} id */ + resolveId(id) { + if (id === virtualId) return resolvedId; + }, + /** @param {string} id */ + load(id) { + if (id !== resolvedId) return; + // Single source of truth: every lexicon in ../lexicons, keyed by its NSID + // id, read raw at build time so docs never drift from the schema. + const lexiconsDir = join(__dirname, "../lexicons"); + /** @type {Record} */ + const lexicons = {}; + /** @param {string} dir */ + const walk = (dir) => { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + if (entry.isDirectory()) { + walk(full); + } else if (entry.name.endsWith(".json")) { + const text = readFileSync(full, "utf-8").trimEnd(); + const id = JSON.parse(text).id; + if (id) lexicons[id] = text; + } + } + }; + walk(lexiconsDir); + return `export const lexicons = ${JSON.stringify(lexicons)};`; + }, + }; +} + function i18nResourcesPlugin() { const virtualId = "virtual:i18n-resources"; const resolvedId = "\0" + virtualId; @@ -89,7 +125,7 @@ export default defineConfig({ defaultStrategy: "viewport", }, vite: { - plugins: [i18nResourcesPlugin(), i18nLanguagesPlugin()], + plugins: [lexiconsPlugin(), i18nResourcesPlugin(), i18nLanguagesPlugin()], resolve: { alias: { "@": resolve(__dirname, "src"), diff --git a/web/src/env.d.ts b/web/src/env.d.ts index 86352c1..a2302c6 100644 --- a/web/src/env.d.ts +++ b/web/src/env.d.ts @@ -1,5 +1,10 @@ /// +declare module "virtual:lexicons" { + /** Raw lexicon JSON text, keyed by NSID id (e.g. "at.margin.note"). */ + export const lexicons: Record; +} + declare module "virtual:i18n-resources" { export const resources: Record< string, diff --git a/web/src/views/About.tsx b/web/src/views/About.tsx index 9799ec1..2ddb8de 100644 --- a/web/src/views/About.tsx +++ b/web/src/views/About.tsx @@ -31,241 +31,8 @@ import { import type { LucideIcon } from "lucide-react"; import { AppleIcon, TangledIcon } from "../components/common/Icons"; import { FaFirefox, FaEdge, FaSafari } from "react-icons/fa"; -const noteLexiconText = `{ - "lexicon": 1, - "id": "at.margin.note", - "revision": 3, - "description": "W3C Web Annotation Data Model compliant unified note record for ATProto", - "defs": { - "main": { - "type": "record", - "description": "A W3C-compliant web annotation stored on the AT Protocol", - "key": "tid", - "record": { - "type": "object", - "required": ["motivation", "target", "createdAt"], - "properties": { - "motivation": { - "type": "string", - "description": "W3C motivation for the annotation", - "knownValues": [ - "commenting", - "highlighting", - "bookmarking", - "tagging", - "describing", - "linking", - "replying", - "editing", - "questioning", - "assessing" - ] - }, - "color": { - "type": "string", - "description": "Highlight color tint", - "maxLength": 20 - }, - "body": { - "type": "ref", - "ref": "#body", - "description": "The annotation content (text or reference). For bookmarks, use body.value for the description." - }, - "target": { - "type": "ref", - "ref": "#target", - "description": "The resource being annotated with optional selector" - }, - "tags": { - "type": "array", - "description": "Tags for categorization", - "items": { - "type": "string", - "maxLength": 64, - "maxGraphemes": 32 - }, - "maxLength": 10 - }, - "facets": { - "type": "array", - "description": "Rich text facets (e.g. mentions, links)", - "items": { - "type": "ref", - "ref": "app.bsky.richtext.facet" - } - }, - "generator": { - "type": "ref", - "ref": "#generator", - "description": "The client/agent that created this record" - }, - "rights": { - "type": "string", - "format": "uri", - "description": "License URI (e.g., https://creativecommons.org/licenses/by/4.0/)" - }, - "labels": { - "type": "ref", - "ref": "com.atproto.label.defs#selfLabels", - "description": "Self-applied content labels for this annotation" - }, - "createdAt": { - "type": "string", - "format": "datetime" - }, - "modifiedAt": { - "type": "string", - "format": "datetime", - "description": "When this record was last modified" - } - } - } - }, - "generator": { - "type": "object", - "description": "The client/agent that created this record", - "properties": { - "id": { - "type": "string", - "format": "uri" - }, - "name": { - "type": "string" - }, - "homepage": { - "type": "string", - "format": "uri" - } - } - }, - "body": { - "type": "object", - "description": "Annotation body - the content of the annotation", - "properties": { - "value": { - "type": "string", - "maxLength": 10000, - "maxGraphemes": 3000, - "description": "Text content of the annotation. For bookmarks, this is the description." - }, - "format": { - "type": "string", - "description": "MIME type of the body content", - "default": "text/plain" - }, - "uri": { - "type": "string", - "format": "uri", - "description": "Reference to external body content" - } - } - }, - "target": { - "type": "object", - "description": "W3C SpecificResource - the target with optional selector", - "required": ["source"], - "properties": { - "source": { - "type": "string", - "format": "uri", - "description": "The URL being annotated" - }, - "sourceHash": { - "type": "string", - "description": "SHA256 hash of normalized URL for indexing" - }, - "title": { - "type": "string", - "maxLength": 500, - "description": "Page title at time of annotation" - }, - "selector": { - "type": "ref", - "ref": "#selector", - "description": "W3C Selector to identify the annotated segment. Uses W3C 'type' field (not ATProto $type) per the Web Annotation Data Model." - }, - "state": { - "type": "ref", - "ref": "#timeState", - "description": "State of the resource at annotation time" - } - } - }, - "selector": { - "type": "object", - "description": "W3C Web Annotation Selector. The 'type' field discriminates the selector kind using W3C type names (e.g. TextQuoteSelector). This follows W3C conventions, not ATProto union $type.", - "required": ["type"], - "properties": { - "type": { - "type": "string", - "description": "W3C selector type identifier", - "knownValues": [ - "TextQuoteSelector", - "TextPositionSelector", - "CssSelector", - "XPathSelector", - "FragmentSelector", - "RangeSelector" - ] - }, - "exact": { - "type": "string", - "maxLength": 5000, - "maxGraphemes": 1500, - "description": "TextQuoteSelector: the exact text being selected" - }, - "prefix": { - "type": "string", - "maxLength": 500, - "maxGraphemes": 150, - "description": "TextQuoteSelector: text immediately before the selection, for disambiguation" - }, - "suffix": { - "type": "string", - "maxLength": 500, - "maxGraphemes": 150, - "description": "TextQuoteSelector: text immediately after the selection, for disambiguation" - }, - "start": { - "type": "integer", - "minimum": 0, - "description": "TextPositionSelector: start character offset (inclusive)" - }, - "end": { - "type": "integer", - "minimum": 0, - "description": "TextPositionSelector: end character offset (exclusive)" - }, - "value": { - "type": "string", - "maxLength": 2000, - "description": "CssSelector/XPathSelector/FragmentSelector: the selector expression or fragment value" - }, - "conformsTo": { - "type": "string", - "format": "uri", - "description": "FragmentSelector: URI of the specification the fragment conforms to" - } - } - }, - "timeState": { - "type": "object", - "description": "W3C TimeState - record when content was captured", - "properties": { - "sourceDate": { - "type": "string", - "format": "datetime", - "description": "When the source was accessed" - }, - "cached": { - "type": "string", - "format": "uri", - "description": "URL to cached/archived version" - } - } - } - } -}`; +import { lexicons } from "virtual:lexicons"; +const noteLexiconText = lexicons["at.margin.note"]; function LexiconBlock() { const [copied, setCopied] = React.useState(false); -- knotmirror 0.0.1