From 1d07c569ff8f5fd19e4f44adfeb3d7e88bf2137d Mon Sep 17 00:00:00 2001 From: JP Hastings-Spital Date: Sat, 27 Jun 2026 15:45:51 +0200 Subject: [PATCH 1/7] Support refinedBy selectors in note records Add `refinedBy` to the W3C selector definition so a selector can be narrowed by another (e.g. a TextQuoteSelector refined by a page/CFI FragmentSelector locating it in a specific edition). - Lexicon: recursive `refinedBy` ref on the selector def - Backend: carry refinedBy through API hydration instead of dropping it - Web types: model selectors as a discriminated union per W3C kind to avoid subtle bugs, `refinedBy` on a shared base; `Selector` remains the union of all - Narrow to `TextQuoteSelector` at write boundaries via `asTextQuote` rather than casting parsed JSON, and require it on the create APIs --- backend/internal/api/hydration.go | 17 ++++--- backend/internal/service/hydration.go | 17 ++++--- lexicons/at/margin/note.json | 7 ++- web/src/api/client.ts | 7 +-- web/src/components/common/Card.tsx | 9 ++-- web/src/components/feed/Composer.tsx | 14 +++--- web/src/components/modals/EditItemModal.tsx | 5 +- web/src/types.ts | 54 +++++++++++++++++++-- web/src/views/core/HighlightImporter.tsx | 4 +- web/src/views/core/Notifications.tsx | 5 +- 10 files changed, 97 insertions(+), 42 deletions(-) diff --git a/backend/internal/api/hydration.go b/backend/internal/api/hydration.go index 8d39ef5..7291ea5 100644 --- a/backend/internal/api/hydration.go +++ b/backend/internal/api/hydration.go @@ -36,14 +36,15 @@ type Author struct { } type APISelector struct { - Type string `json:"type"` - Exact string `json:"exact,omitempty"` - Prefix string `json:"prefix,omitempty"` - Suffix string `json:"suffix,omitempty"` - Start *int `json:"start,omitempty"` - End *int `json:"end,omitempty"` - Value string `json:"value,omitempty"` - ConformsTo string `json:"conformsTo,omitempty"` + Type string `json:"type"` + Exact string `json:"exact,omitempty"` + Prefix string `json:"prefix,omitempty"` + Suffix string `json:"suffix,omitempty"` + Start *int `json:"start,omitempty"` + End *int `json:"end,omitempty"` + Value string `json:"value,omitempty"` + ConformsTo string `json:"conformsTo,omitempty"` + RefinedBy *APISelector `json:"refinedBy,omitempty"` } type APIBody struct { diff --git a/backend/internal/service/hydration.go b/backend/internal/service/hydration.go index 07ce4eb..b1dd451 100644 --- a/backend/internal/service/hydration.go +++ b/backend/internal/service/hydration.go @@ -11,14 +11,15 @@ import ( ) type APISelector struct { - Type string `json:"type"` - Exact string `json:"exact,omitempty"` - Prefix string `json:"prefix,omitempty"` - Suffix string `json:"suffix,omitempty"` - Start *int `json:"start,omitempty"` - End *int `json:"end,omitempty"` - Value string `json:"value,omitempty"` - ConformsTo string `json:"conformsTo,omitempty"` + Type string `json:"type"` + Exact string `json:"exact,omitempty"` + Prefix string `json:"prefix,omitempty"` + Suffix string `json:"suffix,omitempty"` + Start *int `json:"start,omitempty"` + End *int `json:"end,omitempty"` + Value string `json:"value,omitempty"` + ConformsTo string `json:"conformsTo,omitempty"` + RefinedBy *APISelector `json:"refinedBy,omitempty"` } type APIBody struct { diff --git a/lexicons/at/margin/note.json b/lexicons/at/margin/note.json index 527d3c0..8621857 100644 --- a/lexicons/at/margin/note.json +++ b/lexicons/at/margin/note.json @@ -1,7 +1,7 @@ { "lexicon": 1, "id": "at.margin.note", - "revision": 3, + "revision": 4, "description": "W3C Web Annotation Data Model compliant unified note record for ATProto", "defs": { "main": { @@ -212,6 +212,11 @@ "type": "string", "format": "uri", "description": "FragmentSelector: URI of the specification the fragment conforms to" + }, + "refinedBy": { + "type": "ref", + "ref": "#selector", + "description": "W3C refinement: a further selector applied within the region this selector identifies (e.g. a FragmentSelector locating a page or CFI range inside a TextQuoteSelector match)." } } }, diff --git a/web/src/api/client.ts b/web/src/api/client.ts index de70ca0..d987964 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -7,6 +7,7 @@ import type { NotificationItem, Selector, Target, + TextQuoteSelector, UserProfile, } from "../types"; @@ -341,7 +342,7 @@ interface CreateAnnotationParams { url: string; text?: string; title?: string; - selector?: { exact: string; prefix?: string; suffix?: string }; + selector?: TextQuoteSelector; tags?: string[]; labels?: string[]; } @@ -370,7 +371,7 @@ export async function createAnnotation({ interface CreateHighlightParams { url: string; - selector: { exact: string; prefix?: string; suffix?: string }; + selector: TextQuoteSelector; color?: string; tags?: string[]; title?: string; @@ -517,7 +518,7 @@ export async function convertHighlightToAnnotation( highlightUri: string, url: string, text: string, - selector?: { exact: string; prefix?: string; suffix?: string }, + selector?: TextQuoteSelector, title?: string, ): Promise<{ success: boolean; item?: AnnotationItem; error?: string }> { try { diff --git a/web/src/components/common/Card.tsx b/web/src/components/common/Card.tsx index 09fc867..1631321 100644 --- a/web/src/components/common/Card.tsx +++ b/web/src/components/common/Card.tsx @@ -47,6 +47,7 @@ import type { ContentLabel, LabelVisibility, } from "../../types"; +import { asTextQuote } from "../../types"; import { Avatar } from "../ui"; import CollectionIcon from "./CollectionIcon"; @@ -252,7 +253,7 @@ export default function Card({ item.uri, pageUrl, convertText.trim(), - item.target?.selector, + asTextQuote(item.target?.selector), item.target?.title, ); setConverting(false); @@ -346,7 +347,7 @@ export default function Card({ item.target?.title || item.title || (isbn ? `ISBN ${isbn}` : null); const quoteLinkUrl = (() => { - const sel = item.target?.selector; + const sel = asTextQuote(item.target?.selector); if (!sel?.exact) return null; if (bookUrl) return bookUrl; if (!pageUrl) return null; @@ -689,7 +690,7 @@ export default function Card({ )} {!(contentWarning && !contentRevealed) && - item.target?.selector?.exact && ( + asTextQuote(item.target?.selector)?.exact && (
- "{item.target?.selector?.exact}" + "{asTextQuote(item.target?.selector)?.exact}"
)} diff --git a/web/src/components/feed/Composer.tsx b/web/src/components/feed/Composer.tsx index de5eeb6..4dc3e81 100644 --- a/web/src/components/feed/Composer.tsx +++ b/web/src/components/feed/Composer.tsx @@ -8,6 +8,7 @@ import { getTrendingTags, } from "../../api/client"; import type { Selector, ContentLabelValue } from "../../types"; +import { asTextQuote } from "../../types"; import { X, ShieldAlert, Highlighter, PenLine } from "lucide-react"; import TagInput from "../ui/TagInput"; import { analytics } from "../../lib/analytics"; @@ -114,17 +115,14 @@ export default function Composer({ }; } + const quoteSelector = asTextQuote(finalSelector); const tagList = tags.filter(Boolean); if (!text.trim()) { - if (!finalSelector) throw new Error("No text selected"); + if (!quoteSelector) throw new Error("No text selected"); await createHighlight({ url, - selector: finalSelector as { - exact: string; - prefix?: string; - suffix?: string; - }, + selector: quoteSelector, color: "yellow", tags: tagList, labels: selfLabels.length > 0 ? selfLabels : undefined, @@ -139,13 +137,13 @@ export default function Composer({ await createAnnotation({ url, text: text.trim(), - selector: finalSelector || undefined, + selector: quoteSelector, tags: tagList, labels: selfLabels.length > 0 ? selfLabels : undefined, }); analytics.capture("annotation_created", { url, - has_quote: !!finalSelector, + has_quote: !!quoteSelector, tag_count: tagList.length, has_labels: selfLabels.length > 0, }); diff --git a/web/src/components/modals/EditItemModal.tsx b/web/src/components/modals/EditItemModal.tsx index 0f7c487..e8deaa8 100644 --- a/web/src/components/modals/EditItemModal.tsx +++ b/web/src/components/modals/EditItemModal.tsx @@ -10,6 +10,7 @@ import { getTrendingTags, } from "../../api/client"; import type { AnnotationItem, ContentLabelValue } from "../../types"; +import { asTextQuote } from "../../types"; import TagInput from "../ui/TagInput"; const SELF_LABEL_VALUES: ContentLabelValue[] = [ @@ -236,9 +237,9 @@ function EditItemModalContent({ /> ))} - {item.target?.selector?.exact && ( + {asTextQuote(item.target?.selector)?.exact && (
- {item.target.selector.exact} + {asTextQuote(item.target?.selector)?.exact}
)} diff --git a/web/src/types.ts b/web/src/types.ts index 0a59b8f..c4e9edc 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -13,13 +13,59 @@ export interface UserProfile { labels?: ContentLabel[]; } -export interface Selector { - type?: string; +interface BaseSelector { + /** A further selector applied within the region this selector identifies (W3C refinement). */ + refinedBy?: Selector; +} + +export interface TextQuoteSelector extends BaseSelector { + type: "TextQuoteSelector"; exact: string; prefix?: string; suffix?: string; - start?: number; - end?: number; +} + +export interface TextPositionSelector extends BaseSelector { + type: "TextPositionSelector"; + start: number; + end: number; +} + +export interface FragmentSelector extends BaseSelector { + type: "FragmentSelector"; + value: string; + conformsTo?: string; +} + +export interface CssSelector extends BaseSelector { + type: "CssSelector"; + value: string; +} + +export interface XPathSelector extends BaseSelector { + type: "XPathSelector"; + value: string; +} + +export interface RangeSelector extends BaseSelector { + type: "RangeSelector"; + startSelector: Selector; + endSelector: Selector; +} + +export type Selector = + | TextQuoteSelector + | TextPositionSelector + | FragmentSelector + | CssSelector + | XPathSelector + | RangeSelector; + +/** Narrows a selector to a TextQuoteSelector if possible, for reading quote text. */ +export function asTextQuote( + selector?: Selector | null, +): TextQuoteSelector | undefined { + return selector?.type === "TextQuoteSelector" ? selector : undefined; } export interface Target { diff --git a/web/src/views/core/HighlightImporter.tsx b/web/src/views/core/HighlightImporter.tsx index 1740c9a..d94931a 100644 --- a/web/src/views/core/HighlightImporter.tsx +++ b/web/src/views/core/HighlightImporter.tsx @@ -9,7 +9,7 @@ import type React from "react"; import { useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { createHighlight } from "../../api/client"; -import type { Selector } from "../../types"; +import type { TextQuoteSelector } from "../../types"; import { analytics } from "../../lib/analytics"; interface Highlight { @@ -189,7 +189,7 @@ https://blog.example.com,"Another highlight","Article Title","reading",blue,2024 const h = highlights[i]; try { - const selector: Selector = { + const selector: TextQuoteSelector = { type: "TextQuoteSelector", exact: h.text.substring(0, 5000), // Max 5000 chars }; diff --git a/web/src/views/core/Notifications.tsx b/web/src/views/core/Notifications.tsx index fbf68d5..2b028fc 100644 --- a/web/src/views/core/Notifications.tsx +++ b/web/src/views/core/Notifications.tsx @@ -5,6 +5,7 @@ import type { TFunction } from "i18next"; import { getNotifications, markNotificationsRead } from "../../api/client"; import { displayHandle } from "../../lib/handle"; import type { NotificationItem, AnnotationItem } from "../../types"; +import { asTextQuote } from "../../types"; import { Heart, MessageCircle, @@ -153,7 +154,7 @@ function SubjectPreview({ let preview: React.ReactNode = null; if (contentType === "annotation") { - const quote = item?.target?.selector?.exact; + const quote = asTextQuote(item?.target?.selector)?.exact; const body = item?.text || item?.body?.value; preview = ( <> @@ -170,7 +171,7 @@ function SubjectPreview({ ); } else if (contentType === "highlight") { - const quote = item?.target?.selector?.exact; + const quote = asTextQuote(item?.target?.selector)?.exact; preview = quote ? (

“{quote}” -- knotmirror 0.0.1 From d9078bb0df53acdbbfe57ca7f0307e4544a7d33c Mon Sep 17 00:00:00 2001 From: JP Hastings-Spital Date: Sat, 27 Jun 2026 18:31:48 +0200 Subject: [PATCH 2/7] Add physical book page FragmentSelector spec page Document the page= fragment syntax at the stable URL records embed as conformsTo (margin.at/docs/specs/physical-books): an application/x-www-form-urlencoded value with a single defined key, page, holding a page or consecutive range. --- web/src/pages/docs/specs/physical-books.astro | 154 ++++++++++++++++++ 1 file changed, 154 insertions(+) create mode 100644 web/src/pages/docs/specs/physical-books.astro diff --git a/web/src/pages/docs/specs/physical-books.astro b/web/src/pages/docs/specs/physical-books.astro new file mode 100644 index 0000000..67777d3 --- /dev/null +++ b/web/src/pages/docs/specs/physical-books.astro @@ -0,0 +1,154 @@ +--- +import BaseLayout from "../../../layouts/StaticLayout.astro"; + +const SPEC_URL = "https://margin.at/docs/specs/physical-books"; + +// Hand-tokenised at build time so the example highlights with no client JS. +const exampleHtml = `{ + "type": "TextQuoteSelector", + "exact": "Some text from a book.", + "refinedBy": { + "type": "FragmentSelector", + // The URL of the page you're reading now + "conformsTo": "${SPEC_URL}", + // The page number, or consecutive page number range + "value": "page=255-256" + } +}`; + +const examples = [ + { value: "page=20", meaning: "Page 20." }, + { + value: "page=255-256", + meaning: "A range of consecutive pages, 255 to 256.", + }, + { + value: "page=xi", + meaning: "A page printed with the Roman numeral xi (e.g. front matter).", + }, +]; +--- + + +

+
+ + + Home + + +
+
+

+ Specification +

+

+ Physical book pages +

+

+ When you're annotating physical books, you can refine the location of a note with the printed page number(s) they're on with + this FragmentSelector. +

+
+ +
+
+

+ Example +

+
+
+ +
+

+ Value +

+

+ values conforming to this spec are an application/x-www-form-urlencoded string. Today only one valid key is defined, page. +

+

+ The value of page should be the page number(s) where the text fragment can be found, + as written on the printed pages themselves. +

+
// Page 42 of the book
+page=42
+

+ Consecutive page ranges are declared as the first page number, a + hyphen (0x2D), and the last page number, as they appear in the book. +

+
// Spread from page xiv to page 2
+page=xiv-2
+

+ Page numbers containing hyphens should be percent-escaped. +

+
// Spread from page A-1 to page A-2
+page=A%2D1-A%2D2
+

+ Keys other than page have unspecified meaning. +

+
+
+
+
+
+ -- knotmirror 0.0.1 From a21b2d2162f5c395f249ffb7c561533f044d5cdf Mon Sep 17 00:00:00 2001 From: JP Hastings-Spital Date: Sat, 27 Jun 2026 19:38:08 +0200 Subject: [PATCH 3/7] Show printed page number on book notes When a note's selector is refined by a physical-books FragmentSelector, render the page (or range) after the book title, e.g. "There Is No Antimemetics Division (p.255-256)". --- web/src/components/common/Card.tsx | 71 ++++++++++++++++++++++++------ 1 file changed, 58 insertions(+), 13 deletions(-) diff --git a/web/src/components/common/Card.tsx b/web/src/components/common/Card.tsx index 1631321..bfea650 100644 --- a/web/src/components/common/Card.tsx +++ b/web/src/components/common/Card.tsx @@ -46,6 +46,7 @@ import type { AnnotationItem, ContentLabel, LabelVisibility, + Selector, } from "../../types"; import { asTextQuote } from "../../types"; @@ -106,6 +107,40 @@ function getContentWarning( return null; } +const PHYSICAL_BOOKS_SPEC = "https://margin.at/docs/specs/physical-books"; + +// Printed page label from a selector's physical-books FragmentSelector, e.g. +// "255-256", or "A-1 - A-2" when a label itself contains an (encoded) hyphen. +function bookPageLabel(selector?: Selector | null): string | null { + for ( + let sel: Selector | null | undefined = selector; + sel; + sel = sel.refinedBy + ) { + if ( + sel.type !== "FragmentSelector" || + sel.conformsTo !== PHYSICAL_BOOKS_SPEC || + !sel.value + ) { + continue; + } + // Keep the value raw (%2D still encoded) so the range hyphen stays unambiguous. + const raw = sel.value + .split("&") + .map((pair) => pair.split("=")) + .find(([key]) => key === "page")?.[1]; + if (!raw) return null; + try { + const pages = raw.split("-").map(decodeURIComponent); + const separator = /%2d/i.test(raw) ? " - " : "-"; + return pages.join(separator); + } catch { + return null; + } + } + return null; +} + interface CardProps { item: AnnotationItem; onDelete?: (uri: string) => void; @@ -345,6 +380,7 @@ export default function Card({ const bookUrl = isbn ? `https://openlibrary.org/isbn/${isbn}` : null; const bookTitle = item.target?.title || item.title || (isbn ? `ISBN ${isbn}` : null); + const bookPage = bookPageLabel(item.target?.selector); const quoteLinkUrl = (() => { const sel = asTextQuote(item.target?.selector); @@ -566,20 +602,29 @@ export default function Card({ {pageUrl && !isBookmark && !(contentWarning && !contentRevealed) && ( - handleExternalClick(e, bookUrl || pageUrl)} - className="inline-flex items-center gap-1 text-xs text-primary-600 dark:text-primary-400 hover:underline mt-0.5 max-w-full" - > - {isbn ? ( - - ) : ( - +
+ handleExternalClick(e, bookUrl || pageUrl)} + className="inline-flex items-center gap-1 text-xs text-primary-600 dark:text-primary-400 hover:underline min-w-0" + > + {isbn ? ( + + ) : ( + + )} + + {isbn ? bookTitle : displayUrl} + + + {isbn && bookPage && ( + + (p.{bookPage}) + )} - {isbn ? bookTitle : displayUrl} - +
)} -- knotmirror 0.0.1 From c1414518bbe38ad696a90d746327463ac31b5f38 Mon Sep 17 00:00:00 2001 From: JP Hastings-Spital Date: Sat, 27 Jun 2026 22:31:26 +0200 Subject: [PATCH 4/7] Describe the conformsTo requirement --- web/src/pages/docs/specs/physical-books.astro | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/web/src/pages/docs/specs/physical-books.astro b/web/src/pages/docs/specs/physical-books.astro index 67777d3..fd44eba 100644 --- a/web/src/pages/docs/specs/physical-books.astro +++ b/web/src/pages/docs/specs/physical-books.astro @@ -97,6 +97,22 @@ const examples = [
+

+ Conforms To +

+ +

+ For the spec below to apply the conformsTo value must be exactly {SPEC_URL}. +

+

-- knotmirror 0.0.1 From 59795a4a5ccb90110da0de060d4b478b5be389fa Mon Sep 17 00:00:00 2001 From: JP Hastings-Spital Date: Sat, 27 Jun 2026 22:34:00 +0200 Subject: [PATCH 5/7] 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 From 1d1b23398dc583f033cdf2b9ca173bef1f8f8a58 Mon Sep 17 00:00:00 2001 From: JP Hastings-Spital Date: Sat, 27 Jun 2026 23:02:04 +0200 Subject: [PATCH 6/7] Form-decode page fragment so + means space The physical-books spec advertises application/x-www-form-urlencoded, but the page label parser used bare decodeURIComponent, leaving + untouched. Decode each page part as a form component (+ to space, before percent decoding so an escaped %2B survives) so page=Plate+1 renders "Plate 1". --- web/src/components/common/Card.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/web/src/components/common/Card.tsx b/web/src/components/common/Card.tsx index bfea650..f6c2c71 100644 --- a/web/src/components/common/Card.tsx +++ b/web/src/components/common/Card.tsx @@ -109,6 +109,11 @@ function getContentWarning( const PHYSICAL_BOOKS_SPEC = "https://margin.at/docs/specs/physical-books"; +// Decode one application/x-www-form-urlencoded component. "+" maps to a space; +// the replace runs before decodeURIComponent so an escaped plus (%2B) survives. +const formDecode = (component: string): string => + decodeURIComponent(component.replace(/\+/g, " ")); + // Printed page label from a selector's physical-books FragmentSelector, e.g. // "255-256", or "A-1 - A-2" when a label itself contains an (encoded) hyphen. function bookPageLabel(selector?: Selector | null): string | null { @@ -131,7 +136,7 @@ function bookPageLabel(selector?: Selector | null): string | null { .find(([key]) => key === "page")?.[1]; if (!raw) return null; try { - const pages = raw.split("-").map(decodeURIComponent); + const pages = raw.split("-").map(formDecode); const separator = /%2d/i.test(raw) ? " - " : "-"; return pages.join(separator); } catch { -- knotmirror 0.0.1 From cc7d6b1f5f7920432663247b7884b10102e36b78 Mon Sep 17 00:00:00 2001 From: JP Hastings-Spital Date: Sat, 27 Jun 2026 23:08:02 +0200 Subject: [PATCH 7/7] Drop RangeSelector from the lexicon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lexicon has no startSelector/endSelector fields nor the backend APISelector structs, so a RangeSelector could not survive a hydration round-trip — the field would be silently dropped. Nothing produces or consumes it, so remove it until the contract can preserve it. --- lexicons/at/margin/note.json | 3 +-- web/src/types.ts | 9 +-------- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/lexicons/at/margin/note.json b/lexicons/at/margin/note.json index 8621857..c6dcf7a 100644 --- a/lexicons/at/margin/note.json +++ b/lexicons/at/margin/note.json @@ -171,8 +171,7 @@ "TextPositionSelector", "CssSelector", "XPathSelector", - "FragmentSelector", - "RangeSelector" + "FragmentSelector" ] }, "exact": { diff --git a/web/src/types.ts b/web/src/types.ts index c4e9edc..f71d60c 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -47,19 +47,12 @@ export interface XPathSelector extends BaseSelector { value: string; } -export interface RangeSelector extends BaseSelector { - type: "RangeSelector"; - startSelector: Selector; - endSelector: Selector; -} - export type Selector = | TextQuoteSelector | TextPositionSelector | FragmentSelector | CssSelector - | XPathSelector - | RangeSelector; + | XPathSelector; /** Narrows a selector to a TextQuoteSelector if possible, for reading quote text. */ export function asTextQuote( -- knotmirror 0.0.1