···11+# PDS record JSON viewer on single documents — design
22+33+**Date:** 2026-06-11
44+**Status:** Approved, ready for planning
55+66+## Goal
77+88+Add a small "code" icon at the end of the `reader__meta` eyebrow on the public
99+single-document reading page (`/@<handle>/<slug>/<rkey>`). Clicking it opens a modal
1010+showing the exact PDS record body for that post as pretty-printed, syntax-highlighted
1111+JSON. It's a read-only "view the raw record" affordance that builds directly on the
1212+existing frontend syntax-highlighting work.
1313+1414+## Decisions (settled during brainstorming)
1515+1616+| Question | Choice |
1717+| --- | --- |
1818+| What JSON is shown | **The record `value` only** (the `com.skypress.*` document body), *not* the `{ uri, cid }` envelope |
1919+| Source of the data | **The already-resolved `document.value`** on the read path — no extra fetch, no live re-fetch at click time |
2020+| Display | **Modal/dialog overlay** with dimmed backdrop, matching the existing `PostActions` client-island pattern |
2121+| 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) |
2222+| Copy button | **No** — view-only for now |
2323+2424+## Constraints that shaped the design
2525+2626+1. **Untrusted content (AGENTS.md rule 6).** The record `value` comes from arbitrary
2727+ PDSes. The highlighted JSON HTML is injected into the DOM, so it MUST be sanitised
2828+ before reaching the client — same order as the article path (highlight → sanitise).
2929+2. **Reader pages never import `@wordpress/*` (Decision 0003).** The viewer is a plain
3030+ React island (like `PostActions`), and highlighting stays in `highlight.ts`, the
3131+ single home for `highlight.js` — neither touches `@wordpress/*`.
3232+3. **`highlight.js` stays off the client.** The page is SSR (`prerender = false`), so
3333+ the JSON is serialized + highlighted + sanitised server-side and the island receives
3434+ a ready HTML string. No new client-side highlighting dependency.
3535+4. **JS-only enhancement.** `client:only="react"` renders nothing until hydrated; the
3636+ icon is absent without JS rather than showing a dead control.
3737+3838+## Architecture
3939+4040+### Server side — in `[rkey].astro`
4141+4242+At render time, with `document` already resolved on the read path:
4343+4444+```
4545+JSON.stringify(document.value, null, 2) → highlightJson(...) → sanitizeArticleHtml(...)
4646+```
4747+4848+1. Pretty-print the record body with two-space indentation.
4949+2. `highlightJson` tokenises it as JSON (below).
5050+3. `sanitizeArticleHtml` runs as the final step — the highlighter's
5151+ `<span class="hljs-…">` output is already allowlisted by the sanitiser, and
5252+ sanitise neutralises anything hostile that survived escaping.
5353+5454+The resulting HTML string is passed to the island as a prop.
5555+5656+### New export in `src/lib/reader/highlight.ts`
5757+5858+```ts
5959+export function highlightJson( json: string ): string;
6060+```
6161+6262+Behaviour:
6363+- Runs `hljs.highlight( json, { language: 'json' } )` — `json` is already registered
6464+ in this module's curated language set, so no new import.
6565+- Returns `<code class="hljs language-json">${value}</code>` where `value` is
6666+ highlight.js's already-escaped token HTML. (The `<pre>` wrapper is left to the
6767+ island's markup so the modal controls its own chrome.)
6868+- On a highlighter error, falls back to a plain entity-escaped `<code>` — never throws
6969+ into the page render.
7070+7171+This reuses the existing module exactly as `highlightCodeBlocks` does; both share the
7272+registered grammars and the established escape/fallback discipline.
7373+7474+### New client island — `src/components/RecordJsonViewer.tsx`
7575+7676+Mounted `client:only="react"` at the end of the `reader__meta` eyebrow (after
7777+"· N min read"), mirroring how `PostActions` is wired in.
7878+7979+- **Prop**: `highlightedHtml: string` — the sanitised token markup.
8080+- **Trigger**: an inline code icon (`</>`) button, `aria-label="View record JSON"`.
8181+- **Modal**: a dialog with a dimmed backdrop showing the highlighted JSON in a
8282+ scrollable `<pre>`. Dismisses on Esc, backdrop click, and an explicit close button;
8383+ focus moves into the dialog on open and returns to the trigger on close.
8484+- **Fallback slot**: empty (no icon until hydrated), matching `PostActions`' loading
8585+ fallback — this is a JS-only enhancement.
8686+8787+### Styling — `src/styles/record-json.css`
8888+8989+A new global stylesheet imported in the page (the same way `post-actions.css` is),
9090+prefixed `.record-json__*`, built from existing design tokens. Because the existing
9191+`hljs-*` token theme in `[rkey].astro` is scoped to the article container, this
9292+stylesheet carries the same token→`var(--…)` mapping scoped under `.record-json__code`
9393+so the modal is self-contained and doesn't depend on article scope.
9494+9595+## Testing (TDD — failing tests first)
9696+9797+1. **`highlight.test.ts`** (extend): `highlightJson` emits `language-json` token markup
9898+ for a normal record; a string value containing HTML / `</script>` stays escaped (no
9999+ raw tags leak); an empty object is handled.
100100+2. **`RecordJsonViewer.test.tsx`** (new): the icon button renders with its accessible
101101+ label; the modal is absent initially; clicking opens it and the JSON content is
102102+ shown; Esc / close button / backdrop each dismiss it; focus returns to the trigger.
103103+3. **`RecordJsonViewer.presence.test.tsx`** (new, matching the `PostActions`
104104+ convention): the island file exists and default-exports a component taking the
105105+ expected prop.
106106+107107+## Out of scope
108108+109109+- Copy-to-clipboard, the `uri` / `cid` envelope, and re-fetching the live record from
110110+ the PDS at click time.
111111+- Any non-JS fallback view of the record.
112112+- A language picker or showing other record types (publication, profile) — single
113113+ document only.
114114+115115+## Durable rationale
116116+117117+If the "pre-highlight + sanitise the record JSON server-side, hand a ready HTML string
118118+to a thin client island" pattern survives review, note it alongside the existing
119119+highlighting decision (the prospective `docs/decisions/0019-*.md`) rather than as a
120120+separate decision — it's the same highlight → sanitise invariant applied to a second
121121+surface.