A calm place to write long-form, and publish it to the open social web. skypress.blog/
0

Configure Feed

Select the types of activity you want to include in your feed.

Docs: design for PDS record JSON viewer on single documents

+121
+121
docs/superpowers/specs/2026-06-11-record-json-viewer-design.md
··· 1 + # PDS record JSON viewer on single documents — design 2 + 3 + **Date:** 2026-06-11 4 + **Status:** Approved, ready for planning 5 + 6 + ## Goal 7 + 8 + Add a small "code" icon at the end of the `reader__meta` eyebrow on the public 9 + single-document reading page (`/@<handle>/<slug>/<rkey>`). Clicking it opens a modal 10 + showing the exact PDS record body for that post as pretty-printed, syntax-highlighted 11 + JSON. It's a read-only "view the raw record" affordance that builds directly on the 12 + existing frontend syntax-highlighting work. 13 + 14 + ## Decisions (settled during brainstorming) 15 + 16 + | Question | Choice | 17 + | --- | --- | 18 + | What JSON is shown | **The record `value` only** (the `com.skypress.*` document body), *not* the `{ uri, cid }` envelope | 19 + | Source of the data | **The already-resolved `document.value`** on the read path — no extra fetch, no live re-fetch at click time | 20 + | Display | **Modal/dialog overlay** with dimmed backdrop, matching the existing `PostActions` client-island pattern | 21 + | Highlighting | **Reuse the server-side `highlight.js` setup** — pre-highlight on the server, ship ready HTML to the client (no `highlight.js` in the client bundle) | 22 + | Copy button | **No** — view-only for now | 23 + 24 + ## Constraints that shaped the design 25 + 26 + 1. **Untrusted content (AGENTS.md rule 6).** The record `value` comes from arbitrary 27 + PDSes. The highlighted JSON HTML is injected into the DOM, so it MUST be sanitised 28 + before reaching the client — same order as the article path (highlight → sanitise). 29 + 2. **Reader pages never import `@wordpress/*` (Decision 0003).** The viewer is a plain 30 + React island (like `PostActions`), and highlighting stays in `highlight.ts`, the 31 + single home for `highlight.js` — neither touches `@wordpress/*`. 32 + 3. **`highlight.js` stays off the client.** The page is SSR (`prerender = false`), so 33 + the JSON is serialized + highlighted + sanitised server-side and the island receives 34 + a ready HTML string. No new client-side highlighting dependency. 35 + 4. **JS-only enhancement.** `client:only="react"` renders nothing until hydrated; the 36 + icon is absent without JS rather than showing a dead control. 37 + 38 + ## Architecture 39 + 40 + ### Server side — in `[rkey].astro` 41 + 42 + At render time, with `document` already resolved on the read path: 43 + 44 + ``` 45 + JSON.stringify(document.value, null, 2) → highlightJson(...) → sanitizeArticleHtml(...) 46 + ``` 47 + 48 + 1. Pretty-print the record body with two-space indentation. 49 + 2. `highlightJson` tokenises it as JSON (below). 50 + 3. `sanitizeArticleHtml` runs as the final step — the highlighter's 51 + `<span class="hljs-…">` output is already allowlisted by the sanitiser, and 52 + sanitise neutralises anything hostile that survived escaping. 53 + 54 + The resulting HTML string is passed to the island as a prop. 55 + 56 + ### New export in `src/lib/reader/highlight.ts` 57 + 58 + ```ts 59 + export function highlightJson( json: string ): string; 60 + ``` 61 + 62 + Behaviour: 63 + - Runs `hljs.highlight( json, { language: 'json' } )` — `json` is already registered 64 + in this module's curated language set, so no new import. 65 + - Returns `<code class="hljs language-json">${value}</code>` where `value` is 66 + highlight.js's already-escaped token HTML. (The `<pre>` wrapper is left to the 67 + island's markup so the modal controls its own chrome.) 68 + - On a highlighter error, falls back to a plain entity-escaped `<code>` — never throws 69 + into the page render. 70 + 71 + This reuses the existing module exactly as `highlightCodeBlocks` does; both share the 72 + registered grammars and the established escape/fallback discipline. 73 + 74 + ### New client island — `src/components/RecordJsonViewer.tsx` 75 + 76 + Mounted `client:only="react"` at the end of the `reader__meta` eyebrow (after 77 + "· N min read"), mirroring how `PostActions` is wired in. 78 + 79 + - **Prop**: `highlightedHtml: string` — the sanitised token markup. 80 + - **Trigger**: an inline code icon (`</>`) button, `aria-label="View record JSON"`. 81 + - **Modal**: a dialog with a dimmed backdrop showing the highlighted JSON in a 82 + scrollable `<pre>`. Dismisses on Esc, backdrop click, and an explicit close button; 83 + focus moves into the dialog on open and returns to the trigger on close. 84 + - **Fallback slot**: empty (no icon until hydrated), matching `PostActions`' loading 85 + fallback — this is a JS-only enhancement. 86 + 87 + ### Styling — `src/styles/record-json.css` 88 + 89 + A new global stylesheet imported in the page (the same way `post-actions.css` is), 90 + prefixed `.record-json__*`, built from existing design tokens. Because the existing 91 + `hljs-*` token theme in `[rkey].astro` is scoped to the article container, this 92 + stylesheet carries the same token→`var(--…)` mapping scoped under `.record-json__code` 93 + so the modal is self-contained and doesn't depend on article scope. 94 + 95 + ## Testing (TDD — failing tests first) 96 + 97 + 1. **`highlight.test.ts`** (extend): `highlightJson` emits `language-json` token markup 98 + for a normal record; a string value containing HTML / `</script>` stays escaped (no 99 + raw tags leak); an empty object is handled. 100 + 2. **`RecordJsonViewer.test.tsx`** (new): the icon button renders with its accessible 101 + label; the modal is absent initially; clicking opens it and the JSON content is 102 + shown; Esc / close button / backdrop each dismiss it; focus returns to the trigger. 103 + 3. **`RecordJsonViewer.presence.test.tsx`** (new, matching the `PostActions` 104 + convention): the island file exists and default-exports a component taking the 105 + expected prop. 106 + 107 + ## Out of scope 108 + 109 + - Copy-to-clipboard, the `uri` / `cid` envelope, and re-fetching the live record from 110 + the PDS at click time. 111 + - Any non-JS fallback view of the record. 112 + - A language picker or showing other record types (publication, profile) — single 113 + document only. 114 + 115 + ## Durable rationale 116 + 117 + If the "pre-highlight + sanitise the record JSON server-side, hand a ready HTML string 118 + to a thin client island" pattern survives review, note it alongside the existing 119 + highlighting decision (the prospective `docs/decisions/0019-*.md`) rather than as a 120 + separate decision — it's the same highlight → sanitise invariant applied to a second 121 + surface.