diff --git a/apps/web/app/app.css b/apps/web/app/app.css index 028cc6d..c0bbf42 100644 --- a/apps/web/app/app.css +++ b/apps/web/app/app.css @@ -268,4 +268,59 @@ html { font-size: 0.9375rem; color: var(--color-muted); } + + /* + * A compared reading. Inline marks carry the words one copy has and the other + * lacks; a whole block only one copy holds is edged rather than filled, since + * a struck-through section is unreadable and the point is to read it. + */ + .doc ins, + .diff-key-ins { + text-decoration: none; + background: color-mix(in srgb, var(--color-signal-settled) 16%, transparent); + border-radius: 3px; + padding: 0.05em 0.15em; + } + + .doc del, + .diff-key-del { + background: color-mix(in srgb, var(--color-signal-closed) 13%, transparent); + border-radius: 3px; + padding: 0.05em 0.15em; + text-decoration-thickness: 1px; + } + + .diff-key-del { + text-decoration: line-through; + } + + .doc .diff-ins { + border-left: 2px solid var(--color-signal-settled); + padding-left: 1.1rem; + } + + .doc .diff-del { + border-left: 2px solid var(--color-signal-closed); + padding-left: 1.1rem; + } + + /* One edge for a run of blocks: the h2 rule inside would draw a second line. */ + .doc .diff-ins h2, + .doc .diff-del h2 { + border-top: 0; + padding-top: 0; + margin-top: 0; + } + + /* + * An unfolded margin mark: another key's change, shown below the passage it + * concerns. The first heading inside starts a quotation, not a section, so + * it drops the section rule. + */ + .doc .diff-spot h2:first-child, + .doc .diff-spot :first-child > h2:first-child { + border-top: 0; + padding-top: 0; + margin-top: 0; + } } diff --git a/apps/web/app/components/annotated-doc.tsx b/apps/web/app/components/annotated-doc.tsx new file mode 100644 index 0000000..74f37f0 --- /dev/null +++ b/apps/web/app/components/annotated-doc.tsx @@ -0,0 +1,179 @@ +import { useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; +import { createPortal } from "react-dom"; +import { Link } from "react-router"; +import { keyTextColor } from "~/lib/color"; +import { type Authors, authorName } from "~/lib/profile"; +import { authorsState, serverAuthorsState, subscribeAuthors } from "~/lib/profiles"; +import type { Spot } from "~/lib/spots"; + +/** The document's top level elements, skipping the panels this component put there. */ +const blocksIn = (doc: HTMLElement): HTMLElement[] => + [...doc.children].filter( + (child): child is HTMLElement => + child instanceof HTMLElement && child.dataset.spot === undefined, + ); + +const SpotPanel = ({ spot, authors }: { spot: Spot; authors: Authors }) => ( +
+ {spot.entries.map((entry, index) => ( +
0 ? "mt-5 border-t border-rule pt-5" : ""} + > +
+ + {authorName(authors[entry.pubkey] ?? null, entry.npub)} + + + Whole comparison + +
+
+
+ ))} +
+); + +/** + * The document with the places other keys' copies differ marked in its margin. + * A mark unfolds the change below the passage it concerns, so the proposals + * come to the reader instead of waiting behind a comparison page. + * + * The marks are measured against the rendered elements and the panels are + * inserted among them, because the document itself is one block of HTML the + * server rendered: annotating it must not mean re-rendering it. + */ +export const AnnotatedDoc = ({ html, spots }: { html: string; spots: Spot[] }) => { + const docRef = useRef(null); + const kept = useRef>({}); + const [open, setOpen] = useState>(new Set()); + const [tops, setTops] = useState>({}); + const [slots, setSlots] = useState>({}); + const authors = useSyncExternalStore(subscribeAuthors, authorsState, serverAuthorsState); + + /** + * One object per document, not one per render: React re-applies + * `dangerouslySetInnerHTML` whenever the wrapper object is new, and setting + * `innerHTML` again destroys the panels this component planted in the markup. + */ + const markup = useMemo(() => ({ __html: html }), [html]); + + useEffect(() => { + const doc = docRef.current; + if (doc === null) return; + const blocks = blocksIn(doc); + const next: Record = {}; + for (const spot of spots) { + if (!open.has(spot.element)) continue; + const existing = kept.current[spot.element]; + if (existing?.isConnected) { + next[spot.element] = existing; + continue; + } + const slot = document.createElement("div"); + slot.dataset.spot = ""; + slot.className = "diff-spot"; + if (spot.element < 0) { + doc.insertBefore(slot, doc.firstChild); + } else { + const target = blocks[spot.element]; + if (target === undefined) continue; + target.insertAdjacentElement("afterend", slot); + } + next[spot.element] = slot; + } + for (const [key, slot] of Object.entries(kept.current)) { + if (next[Number(key)] === undefined) slot.remove(); + } + kept.current = next; + setSlots(next); + }, [open, spots]); + + useEffect( + () => () => { + for (const slot of Object.values(kept.current)) slot.remove(); + }, + [], + ); + + // Positions follow the text: images load, panels open, the window narrows. + useLayoutEffect(() => { + const doc = docRef.current; + if (doc === null) return; + const measure = () => { + const blocks = blocksIn(doc); + const next: Record = {}; + for (const spot of spots) { + if (spot.element < 0) { + next[spot.element] = 0; + continue; + } + const block = blocks[spot.element]; + if (block !== undefined) next[spot.element] = block.offsetTop; + } + setTops(next); + }; + measure(); + const observer = new ResizeObserver(measure); + observer.observe(doc); + return () => observer.disconnect(); + }, [spots]); + + const toggle = (element: number) => + setOpen((current) => { + const next = new Set(current); + if (next.has(element)) { + next.delete(element); + } else { + next.add(element); + } + return next; + }); + + return ( +
+
+ {spots.map((spot) => { + const top = tops[spot.element]; + if (top === undefined) return null; + const keys = new Set(spot.entries.map((entry) => entry.pubkey)).size; + const label = `${keys === 1 ? "1 key changes" : `${keys} keys change`} this passage`; + return ( + + ); + })} + {spots.map((spot) => { + const slot = slots[spot.element]; + if (slot === undefined) return null; + return createPortal( + , + slot, + String(spot.element), + ); + })} +
+ ); +}; diff --git a/apps/web/app/components/variants.tsx b/apps/web/app/components/variants.tsx new file mode 100644 index 0000000..f571c8c --- /dev/null +++ b/apps/web/app/components/variants.tsx @@ -0,0 +1,114 @@ +import { useEffect, useSyncExternalStore } from "react"; +import { Link } from "react-router"; +import { keyTextColor } from "~/lib/color"; +import { diffPath } from "~/lib/paths"; +import { authorName } from "~/lib/profile"; +import { authorsState, serverAuthorsState, subscribeAuthors, wantAuthors } from "~/lib/profiles"; +import type { Standing } from "~/lib/spots"; +import type { Variant } from "~/lib/variants"; +import { AuthorAvatar } from "./author-avatar"; + +export const VARIANTS_ID = "under-this-name"; + +const asDate = (seconds: number): string => new Date(seconds * 1000).toISOString().slice(0, 10); + +/** + * What their copy is, against the one on this page, in the fewest words that + * stay honest: how many passages it changes, that it reads the same, or that + * it only shares the name. + */ +const said = (standing: Standing): string => { + if (standing.kind === "same") return "reads the same"; + if (standing.kind === "independent") return "its own writing"; + return `changes ${standing.places} ${standing.places === 1 ? "passage" : "passages"}`; +}; + +/** + * The other documents published under this page's identifier. Drawn dashed like + * everything on this site that is available rather than settled: nobody vouches + * for these, they exist, and existing is the whole point. + */ +export const Variants = ({ + variants, + standings, + from, +}: { + variants: Variant[]; + standings: Record; + /** The document whose page this section sits on, and a comparison's base side. */ + from: { npub: string; identifier: string }; +}) => { + const authors = useSyncExternalStore(subscribeAuthors, authorsState, serverAuthorsState); + + useEffect(() => { + if (variants.length > 0) wantAuthors(variants.map((variant) => variant.pubkey)); + }, [variants]); + + if (variants.length === 0) return null; + + return ( +
+

+ Under this name +

+

+ Anyone may publish a document under this name. These are the other keys that have. +

+
    + {variants.map((variant) => { + const author = authors[variant.pubkey] ?? null; + const standing = standings[variant.pubkey]; + return ( +
  • + + + +
    +
    + + {authorName(author, variant.npub)} + + +
    + + {variant.title} + + {variant.summary !== "" && ( +

    + {variant.summary} +

    + )} +
    + + Compare + + {standing !== undefined && ( + {said(standing)} + )} +
    +
    +
  • + ); + })} +
+
+ ); +}; diff --git a/apps/web/app/lib/diff.server.ts b/apps/web/app/lib/diff.server.ts new file mode 100644 index 0000000..8e21f2c --- /dev/null +++ b/apps/web/app/lib/diff.server.ts @@ -0,0 +1,17 @@ +import { diffMarkdown, KINSHIP_FLOOR, type MarkdownDiff } from "@openspecs/markdown"; +import { createLoadCache } from "./cache.server"; +import { mentionResolver } from "./mention"; +import type { CachedSpec } from "./specs.server"; + +export { KINSHIP_FLOOR }; + +/** + * Keyed on the two revisions, so a diff is computed once per pair however many + * readers open it, and a republished document makes a new key by itself. + */ +const diffs = createLoadCache({ max: 100, ttlMs: 60 * 60 * 1000 }); + +export const loadDiff = (base: CachedSpec, other: CachedSpec): Promise => + diffs.get(`${base.page.eventId}:${other.page.eventId}`, async () => + diffMarkdown(base.event.content, other.event.content, { mention: mentionResolver({}) }), + ); diff --git a/apps/web/app/lib/paths.ts b/apps/web/app/lib/paths.ts index f719618..18f3b0d 100644 --- a/apps/web/app/lib/paths.ts +++ b/apps/web/app/lib/paths.ts @@ -73,6 +73,10 @@ export const oembedPath = (canonical: string): string => export const eventPath = (npub: string, identifier: string): string => `/spec/${npub}/${encodeURIComponent(identifier)}/event.json`; +/** What another key's document under the same name changes against this one. */ +export const diffPath = (npub: string, identifier: string, otherNpub: string): string => + `/spec/${npub}/${encodeURIComponent(identifier)}/diff/${otherNpub}`; + /** A document nobody has written yet, which belongs to whichever key is connected. */ export const newSpecPath = (): string => "/new"; diff --git a/apps/web/app/lib/spots.test.ts b/apps/web/app/lib/spots.test.ts new file mode 100644 index 0000000..2936dd4 --- /dev/null +++ b/apps/web/app/lib/spots.test.ts @@ -0,0 +1,102 @@ +import { parseSpec, SPEC_KIND, toNpub } from "@openspecs/nostr"; +import { describe, expect, it } from "vitest"; +import { compareCopies, type SpotBase } from "./spots"; + +const BASE_KEY = "1336a17e161d0e8af2b68ee95ad2a479fc38bef96a17d6127ea02a40d28dd97e"; +const OTHER = "2446a17e161d0e8af2b68ee95ad2a479fc38bef96a17d6127ea02a40d28dd97e"; +const THIRD = "3556a17e161d0e8af2b68ee95ad2a479fc38bef96a17d6127ea02a40d28dd97e"; + +const CONTENT = "# NIP-00\n\nOne says a full sentence.\n\nTwo says a full sentence."; + +const base: SpotBase = { + content: CONTENT, + title: "NIP-00", + npub: toNpub(BASE_KEY), + identifier: "nip-00", +}; + +const specOf = (pubkey: string, content: string) => { + const spec = parseSpec({ + id: "a".repeat(64), + pubkey, + created_at: 1, + kind: SPEC_KIND, + tags: [["d", "nip-00"]], + content, + sig: "b".repeat(128), + }); + if (spec === null) throw new Error("the fixture does not parse as a document"); + return spec; +}; + +describe("compareCopies", () => { + it("marks a changed paragraph at its rendered element, past the dropped title", () => { + // The title heading is block 0 but the page drops it, so the second + // paragraph is the page's element 1. + const other = specOf(OTHER, CONTENT.replace("Two says", "Two now says")); + const { spots } = compareCopies(base, [other]); + expect(spots).toHaveLength(1); + expect(spots[0]?.element).toBe(1); + expect(spots[0]?.entries[0]).toMatchObject({ + pubkey: OTHER, + diffHref: `/spec/${base.npub}/nip-00/diff/${toNpub(OTHER)}`, + }); + expect(spots[0]?.entries[0]?.html).toContain(""); + }); + + it("groups two keys touching the same passage into one spot", () => { + const { spots } = compareCopies(base, [ + specOf(OTHER, CONTENT.replace("Two says", "Two now says")), + specOf(THIRD, CONTENT.replace("Two says", "Two also says")), + ]); + expect(spots).toHaveLength(1); + expect(spots[0]?.entries.map((entry) => entry.pubkey)).toEqual([OTHER, THIRD]); + }); + + it("marks nothing for a copy below the kinship floor", () => { + const other = specOf(OTHER, "Entirely different words about an unrelated idea altogether."); + const { spots, standings } = compareCopies(base, [other]); + expect(spots).toEqual([]); + expect(standings[OTHER]).toEqual({ kind: "independent" }); + }); + + it("marks blocks added before everything at element minus one", () => { + const other = specOf(OTHER, `A fresh opening block.\n\n${CONTENT}`); + const { spots } = compareCopies(base, [other]); + expect(spots).toHaveLength(1); + expect(spots[0]?.element).toBe(-1); + }); + + it("keeps two separated passages as two spots, in reading order", () => { + const between = `${CONTENT}\n\nThree stays put in both copies.\n\nFour says a full sentence.`; + const other = specOf( + OTHER, + between.replace("One says", "One now says").replace("Four says", "Four now says"), + ); + const { spots } = compareCopies({ ...base, content: between }, [other]); + expect(spots.map((spot) => spot.element)).toEqual([0, 3]); + }); + + it("covers a contiguous run of changed passages with one spot", () => { + const other = specOf( + OTHER, + CONTENT.replace("One says", "One now says").replace("Two says", "Two now says"), + ); + const { spots } = compareCopies(base, [other]); + expect(spots.map((spot) => spot.element)).toEqual([0]); + }); + + it("tells a copy's standing: how many passages it changes", () => { + const other = specOf(OTHER, CONTENT.replace("Two says", "Two now says")); + const { standings } = compareCopies(base, [other]); + expect(standings[OTHER]).toEqual({ kind: "kin", places: 1 }); + }); + + it("tells a copy reading the same, even when only a link target differs", () => { + const linked = `${CONTENT.replace("sentence.", "sentence with [a link](./a.md).")}`; + const other = specOf(OTHER, linked.replace("./a.md", "./b.md")); + const { spots, standings } = compareCopies({ ...base, content: linked }, [other]); + expect(spots).toEqual([]); + expect(standings[OTHER]).toEqual({ kind: "same" }); + }); +}); diff --git a/apps/web/app/lib/spots.ts b/apps/web/app/lib/spots.ts new file mode 100644 index 0000000..6c0cf77 --- /dev/null +++ b/apps/web/app/lib/spots.ts @@ -0,0 +1,107 @@ +import { + type BlockAnchor, + blockAnchors, + compareMarkdown, + KINSHIP_FLOOR, + type MarkdownChange, +} from "@openspecs/markdown"; +import { type Spec, toNpub } from "@openspecs/nostr"; +import { mentionResolver } from "./mention"; +import { diffPath } from "./paths"; + +export type SpotEntry = { + pubkey: string; + npub: string; + /** The change as the comparison renders it, sanitized by the same pipeline as the page. */ + html: string; + /** The whole comparison, read from this document's side. */ + diffHref: string; +}; + +/** One place in the document where other keys' copies differ, however many do. */ +export type Spot = { + /** + * Which of the page's rendered top level elements the marker sits at, `-1` + * for changes before everything. The panel always unfolds below the element: + * whether the passage reads differently there or new blocks follow it, below + * is where the reader's eye goes next. + */ + element: number; + entries: SpotEntry[]; +}; + +/** + * A change anchored on source blocks, placed among the page's elements. The + * page renders fewer elements than the source has blocks, so the position is + * the count of rendered blocks before the anchor. A change to a block the page + * draws nothing for marks nothing: there is no place to put it. + */ +const placeOf = (anchors: BlockAnchor[], change: MarkdownChange): number | null => { + const renderedBefore = (index: number) => + anchors.slice(0, index).filter((anchor) => anchor.rendered).length; + + if (change.placement === "after") { + return change.anchor < 0 ? -1 : renderedBefore(change.anchor + 1) - 1; + } + const anchor = anchors[change.anchor]; + if (anchor === undefined || !anchor.rendered) return null; + return renderedBefore(change.anchor); +}; + +export type SpotBase = { + content: string; + title: string; + npub: string; + identifier: string; +}; + +/** Where a copy stands against this document, said in one short phrase on its card. */ +export type Standing = { kind: "kin"; places: number } | { kind: "same" } | { kind: "independent" }; + +export type CopiesComparison = { + spots: Spot[]; + /** By the copy's pubkey. A copy whose comparison never ran has no standing. */ + standings: Record; +}; + +/** + * Every place where another key's copy departs from this document, grouped by + * the element it concerns so five copies touching one paragraph make one mark, + * and each copy's standing alongside. A copy below the kinship floor annotates + * nothing: it would mark every paragraph, and its card says it is its own + * writing instead. + */ +export const compareCopies = (base: SpotBase, others: Spec[]): CopiesComparison => { + const anchors = blockAnchors(base.content, base.title); + const spots = new Map(); + const standings: Record = {}; + + for (const other of others) { + const comparison = compareMarkdown(base.content, other.content, { + mention: mentionResolver({}), + }); + if (comparison.similarity < KINSHIP_FLOOR) { + standings[other.pubkey] = { kind: "independent" }; + continue; + } + standings[other.pubkey] = + comparison.changes.length === 0 + ? { kind: "same" } + : { kind: "kin", places: comparison.changes.length }; + + const npub = toNpub(other.pubkey); + for (const change of comparison.changes) { + const element = placeOf(anchors, change); + if (element === null) continue; + const spot = spots.get(element) ?? { element, entries: [] }; + spot.entries.push({ + pubkey: other.pubkey, + npub, + html: change.html, + diffHref: diffPath(base.npub, base.identifier, npub), + }); + spots.set(element, spot); + } + } + return { spots: [...spots.values()].sort((a, b) => a.element - b.element), standings }; +}; diff --git a/apps/web/app/lib/variants.test.ts b/apps/web/app/lib/variants.test.ts new file mode 100644 index 0000000..83541f5 --- /dev/null +++ b/apps/web/app/lib/variants.test.ts @@ -0,0 +1,51 @@ +import { parseSpec, SPEC_KIND, toNpub } from "@openspecs/nostr"; +import { describe, expect, it } from "vitest"; +import { selectVariants } from "./variants"; + +const SHOWN = "1336a17e161d0e8af2b68ee95ad2a479fc38bef96a17d6127ea02a40d28dd97e"; +const OTHER = "2446a17e161d0e8af2b68ee95ad2a479fc38bef96a17d6127ea02a40d28dd97e"; +const THIRD = "3556a17e161d0e8af2b68ee95ad2a479fc38bef96a17d6127ea02a40d28dd97e"; + +const specOf = (pubkey: string, content = "text", createdAt = 1) => { + const spec = parseSpec({ + id: "a".repeat(64), + pubkey, + created_at: createdAt, + kind: SPEC_KIND, + tags: [ + ["d", "nip-07"], + ["title", "NIP-07"], + ], + content, + sig: "b".repeat(128), + }); + if (spec === null) throw new Error("the fixture does not parse as a document"); + return spec; +}; + +describe("selectVariants", () => { + it("drops the key whose document the page already is", () => { + const variants = selectVariants([specOf(SHOWN), specOf(OTHER)], SHOWN); + expect(variants.map((variant) => variant.pubkey)).toEqual([OTHER]); + }); + + it("drops blank records, the way listings do", () => { + expect(selectVariants([specOf(OTHER, " ")], SHOWN)).toEqual([]); + }); + + it("lists the newest revision first", () => { + const variants = selectVariants([specOf(OTHER, "text", 1), specOf(THIRD, "text", 2)], SHOWN); + expect(variants.map((variant) => variant.pubkey)).toEqual([THIRD, OTHER]); + }); + + it("carries what a row draws", () => { + const [variant] = selectVariants([specOf(OTHER, "text", 7)], SHOWN); + expect(variant).toMatchObject({ + pubkey: OTHER, + npub: toNpub(OTHER), + title: "NIP-07", + revisedAt: 7, + path: `/spec/${toNpub(OTHER)}/nip-07`, + }); + }); +}); diff --git a/apps/web/app/lib/variants.ts b/apps/web/app/lib/variants.ts new file mode 100644 index 0000000..baa4e79 --- /dev/null +++ b/apps/web/app/lib/variants.ts @@ -0,0 +1,116 @@ +import { fetchSpecs, type NostrEvent, type Spec, specPath, toNpub } from "@openspecs/nostr"; +import { useEffect, useState } from "react"; +import { eventPath } from "./paths"; +import { compareCopies, type Spot, type Standing } from "./spots"; + +/** Another key's document under the same identifier, reduced to what its row draws. */ +export type Variant = { + pubkey: string; + npub: string; + title: string; + summary: string; + revisedAt: number; + path: string; +}; + +/** + * The other keys publishing under this identifier. The one on the page is + * dropped because its document is the page, and blank records are dropped the + * way listings drop them. Newest revision first, which is the order everything + * else on this site lists in: any other order would be a ranking, and ranking + * the versions of a name is the one call this site refuses to make. + */ +export const selectVariants = (specs: Spec[], shownPubkey: string): Variant[] => + specs + .filter((spec) => spec.pubkey !== shownPubkey && !spec.isEmpty) + .sort((a, b) => b.createdAt - a.createdAt) + .map((spec) => ({ + pubkey: spec.pubkey, + npub: toNpub(spec.pubkey), + title: spec.title, + summary: spec.summary, + revisedAt: spec.createdAt, + path: specPath(spec), + })); + +export type UnderThisName = { + variants: Variant[]; + /** The places in the shown revision where those copies differ, for the margin. */ + spots: Spot[]; + /** Each copy's standing against the shown revision, for its card. */ + standings: Record; +}; + +const NOTHING: UnderThisName = { variants: [], spots: [], standings: {} }; + +/** + * The marks must sit on the text the reader is looking at, so the base source + * has to be the shown revision exactly. The relays usually hand it back in the + * same query that found the variants; when they hold another revision, the + * server's own copy of the event is the one the page was rendered from. + */ +const baseContentOf = async ( + specs: Spec[], + shown: { pubkey: string; npub: string; identifier: string; eventId: string }, +): Promise => { + const own = specs.find((spec) => spec.pubkey === shown.pubkey); + if (own !== undefined && own.event.id === shown.eventId) return own.content; + try { + const response = await fetch(eventPath(shown.npub, shown.identifier)); + if (!response.ok) return null; + const event = (await response.json()) as NostrEvent; + return event.id === shown.eventId ? event.content : null; + } catch { + return null; + } +}; + +/** + * Asked by the browser after the page is on screen, like the discussion and for + * the same reason: the server renders the document for crawlers and cachers, and + * who else signs its name is not part of the document. When the indexer backend + * exists, this one query is what it replaces. + * + * The variants are published first and the margin marks after: the section is a + * list to draw, the marks cost a comparison per copy, and neither should wait + * on the other. + */ +export const useVariants = (shown: { + pubkey: string; + npub: string; + identifier: string; + eventId: string; + title: string; +}): UnderThisName => { + const [found, setFound] = useState(NOTHING); + const { pubkey, npub, identifier, eventId, title } = shown; + + useEffect(() => { + let live = true; + setFound(NOTHING); + + (async () => { + try { + const specs = await fetchSpecs({ identifiers: [identifier] }); + if (!live) return; + const variants = selectVariants(specs, pubkey); + setFound({ variants, spots: [], standings: {} }); + if (variants.length === 0) return; + + const content = await baseContentOf(specs, { pubkey, npub, identifier, eventId }); + if (!live || content === null) return; + const others = specs.filter((spec) => spec.pubkey !== pubkey && !spec.isEmpty); + const { spots, standings } = compareCopies({ content, title, npub, identifier }, others); + if (live) setFound({ variants, spots, standings }); + } catch { + // A page that could not learn who else signs this name still has its document. + } + })(); + + return () => { + live = false; + }; + }, [pubkey, npub, identifier, eventId, title]); + + return found; +}; diff --git a/apps/web/app/routes.ts b/apps/web/app/routes.ts index 4138f8c..68905c9 100644 --- a/apps/web/app/routes.ts +++ b/apps/web/app/routes.ts @@ -11,6 +11,7 @@ export default [ route("atom.xml", "routes/atom.ts"), route("spec/:author/:identifier", "routes/spec.tsx"), route("spec/:author/:identifier/edit", "routes/spec-edit.tsx"), + route("spec/:author/:identifier/diff/:other", "routes/spec-diff.tsx"), route("spec/:author/:identifier/event.json", "routes/event.ts"), route("og/:author/:identifier", "routes/og.ts"), route("og/:author", "routes/og-author.ts"), diff --git a/apps/web/app/routes/spec-diff.tsx b/apps/web/app/routes/spec-diff.tsx new file mode 100644 index 0000000..9774aad --- /dev/null +++ b/apps/web/app/routes/spec-diff.tsx @@ -0,0 +1,169 @@ +import { parsePubkey, resolveNip05, specPath, toNpub } from "@openspecs/nostr"; +import { data, Link, redirect } from "react-router"; +import { ErrorPage } from "~/components/error-page"; +import { Shell } from "~/components/shell"; +import { keyTextColor } from "~/lib/color"; +import { KINSHIP_FLOOR, loadDiff } from "~/lib/diff.server"; +import { NOT_FOUND_HEADERS, PAGE_HEADERS } from "~/lib/http"; +import { diffPath } from "~/lib/paths"; +import { authorName } from "~/lib/profile"; +import { loadAuthor } from "~/lib/profile.server"; +import { loadSpec } from "~/lib/specs.server"; +import type { Route } from "./+types/spec-diff"; + +const resolve = async (author: string): Promise => + parsePubkey(author) ?? (await resolveNip05(author))?.pubkey ?? null; + +export async function loader({ params }: Route.LoaderArgs) { + const [pubkey, otherPubkey] = await Promise.all([resolve(params.author), resolve(params.other)]); + if (pubkey === null || otherPubkey === null) { + throw data({ missing: "address" }, { status: 404, headers: NOT_FOUND_HEADERS }); + } + + // A document diffed against itself is the document, which has its own page. + if (pubkey === otherPubkey) throw redirect(specPath({ pubkey, identifier: params.identifier })); + + const path = diffPath(toNpub(pubkey), params.identifier, toNpub(otherPubkey)); + if (params.author !== toNpub(pubkey) || params.other !== toNpub(otherPubkey)) { + throw redirect(path, 301); + } + + const [base, other] = await Promise.all([ + loadSpec(pubkey, params.identifier), + loadSpec(otherPubkey, params.identifier), + ]); + if (base === null || other === null) { + throw data({ missing: "document" }, { status: 404, headers: NOT_FOUND_HEADERS }); + } + + const [diff, baseAuthor, otherAuthor] = await Promise.all([ + loadDiff(base, other), + loadAuthor(pubkey), + loadAuthor(otherPubkey), + ]); + + return { + base: base.page, + other: other.page, + diff, + // Decided here rather than shipped as a threshold: the browser never + // imports the server module this constant lives in. + kin: diff.similarity >= KINSHIP_FLOOR, + baseName: authorName(baseAuthor, base.page.npub), + otherName: authorName(otherAuthor, other.page.npub), + }; +} + +export function headers({ errorHeaders }: Route.HeadersArgs) { + return errorHeaders ?? PAGE_HEADERS; +} + +/** + * Never indexed: the two documents are the record, and this page is a reading + * of them that exists for whoever asked for it. + */ +export function meta({ loaderData }: Route.MetaArgs) { + if (!loaderData) + return [{ title: "Not found | Open Specs" }, { name: "robots", content: "noindex" }]; + const { base, otherName } = loaderData; + return [ + { title: `${base.identifier}: what ${otherName}'s copy changes | Open Specs` }, + { name: "robots", content: "noindex" }, + ]; +} + +const KeyName = ({ + pubkey, + npub, + identifier, + name, +}: { + pubkey: string; + npub: string; + identifier: string; + name: string; +}) => ( + + {name} + +); + +export default function SpecDiff({ loaderData }: Route.ComponentProps) { + const { base, other, diff, kin, baseName, otherName } = loaderData; + const shared = Math.round(diff.similarity * 100); + + const baseLink = ( + + ); + const otherLink = ( + + ); + + return ( + +
+
+

+ {base.kind}:{base.identifier} +

+

+ What {otherName}'s copy changes +

+

+ The document signed by {baseLink}, read whole, with what the copy signed by {otherLink}{" "} + changes marked in place. +

+ +
+ {diff.changed && kin && ( + <> + only in {otherName}'s + only in {baseName}'s + + )} + sharing {shared}% of their words + + Swap sides + +
+
+ +
+ {!diff.changed ? ( +

+ The two documents read the same. The signatures and the dates differ, and nothing in + the text does. +

+ ) : kin ? ( + // Sanitized by the same pipeline as every document page, block by block. + // biome-ignore lint/security/noDangerouslySetInnerHtml: rendered Markdown +
+ ) : ( +

+ These two share a name, not a text: they hold {shared}% of their words in common, and + marking the differences would mark nearly everything. Read them side by side instead: + the copy signed by {baseLink}, and the one signed by {otherLink}. +

+ )} +
+
+
+ ); +} + +export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) { + return ; +} diff --git a/apps/web/app/routes/spec.tsx b/apps/web/app/routes/spec.tsx index 9f44f31..c2a0d78 100644 --- a/apps/web/app/routes/spec.tsx +++ b/apps/web/app/routes/spec.tsx @@ -8,6 +8,7 @@ import { toNpub, } from "@openspecs/nostr"; import { data, Link, redirect } from "react-router"; +import { AnnotatedDoc } from "~/components/annotated-doc"; import { AuthorAvatar } from "~/components/author-avatar"; import { CopyButton } from "~/components/copy-button"; import { DISCUSSION_ID, Discussion } from "~/components/discussion/discussion"; @@ -17,6 +18,7 @@ import { ErrorPage } from "~/components/error-page"; import { Rebroadcast } from "~/components/rebroadcast"; import { Shell } from "~/components/shell"; import { SpecTags } from "~/components/spec-tags"; +import { VARIANTS_ID, Variants } from "~/components/variants"; import { keyTextColor } from "~/lib/color"; import { NOT_FOUND_HEADERS, PAGE_HEADERS } from "~/lib/http"; import { useLiveRevision } from "~/lib/live-revision"; @@ -29,6 +31,7 @@ import { loadAuthor } from "~/lib/profile.server"; import { discussionRelays, rebroadcastRelays } from "~/lib/relays.server"; import type { SpecPage } from "~/lib/spec-page"; import { loadSpec } from "~/lib/specs.server"; +import { useVariants } from "~/lib/variants"; import type { Route } from "./+types/spec"; export async function loader({ params, request }: Route.LoaderArgs) { @@ -159,7 +162,13 @@ const Field = ({ label, children }: { label: string; children: React.ReactNode }
); -const Contents = ({ headings }: { headings: MarkdownHeading[] }) => ( +const Contents = ({ + headings, + variantCount, +}: { + headings: MarkdownHeading[]; + variantCount: number; +}) => (