how the markdown renderer drops into the comment rail, and the one decision that carries real risk.
summary
Render comment bodies as Markdown in the kernel/just-html comment rail by introducing one shared client component, <CommentMarkdown body={...}/>, built on react-markdown v10 + remark-gfm + remark-breaks (the exact stack hypeship uses), and swapping it into the two plain-text render sites in app/d/[slug]/CommentsShell.tsx (root body line 903, reply body line 930). The load-bearing decision is sanitization: comment bodies are untrusted and the rail renders in the justhtml.sh shell origin (cookies/session), NOT the sandboxed doc iframe, so this is a real stored-XSS surface. We rely on react-markdown v10's safe-by-default behavior (no rehype-raw, no allowDangerousHtml) AND add rehype-sanitize as defense-in-depth, because a single future PR that flips raw-HTML on would otherwise become an account-takeover bug. Styling is a scoped .jh-md CSS class injected via a <style> block (the file's existing RAIL_CSS pattern), reading the established --jh-card-* vars so it themes in both light and adaptive-dark and survives the ~320px rail. Defer Shiki: the sample comments contain a fenced SQL block, but Shiki's client bundle (multi-MB of grammars/themes) is unjustifiable for a 320px rail — code blocks render as styled, scrollable, monospaced <pre> instead.
recommended library
react-markdown@10.1.0 + remark-gfm@4.0.1 + remark-breaks@4.0.0 + rehype-sanitize@6.0.0, all as runtime dependencies. These match the kernel/hypeship dashboard stack exactly (react-markdown 10.1.0 / remark-gfm 4.0.1 / remark-breaks 4.0.0) — react-markdown v10 already peer-supports React 19, so it drops into this repo's React 19.1.0 with no override. The one deliberate addition over hypeship is rehype-sanitize@6.0.0: hypeship omits it and leans purely on v10 defaults, which is acceptable for trusted dashboard content but NOT for untrusted comment bodies rendering in a session-bearing origin (see security). DELIBERATE OMISSION: shiki/rehype-pretty-code — hypeship uses Shiki for <code> highlighting, but it ships megabytes of WASM grammars + dual themes to the client; for a 320px comment rail that is the wrong trade, so code blocks get a plain styled <pre> and Shiki is deferred behind a documented follow-up. Bundle cost of the chosen set is roughly react-markdown core + mdast/hast pipeline (~40-60KB gzipped) plus remark-gfm's table/strikethrough/autolink microsyntaxes (~10KB) — added to a route that is already a heavy 'use client' bundle, so the marginal cost is acceptable. Alternative considered: a hand-rolled regex/markdown-to-jsx micro-renderer (zero deps) — rejected because GFM tables + nested lists + fenced code (all present in the sample fixtures /home/agent/jh-design/sample-long.md) are exactly where naive renderers either mis-parse or re-introduce an injection hole; matching hypeship's audited pipeline is safer and is the explicit instruction.
steps
-
1Add the four runtime deps (npm, not bun)
package.json at /home/agent/just-html/package.json pins exact deps and uses npm (package-lock.json present, packageManager npm@10.9.2 line 41; there is NO bun.lock, so do NOT use bun). Run: npm install react-markdown@10.1.0 remark-gfm@4.0.1 remark-breaks@4.0.0 rehype-sanitize@6.0.0 --save-exact. They belong in dependencies (not devDependencies) — they execute at render time. Confirm package-lock.json updates and commit it. There are currently NO markdown deps (verified: dependencies block lines 18-27 has only next/react/react-dom/pg/resend/zod/@pierre/diffs/@asteasolutions).
-
2Create the shared component lib/docs/comments/CommentMarkdown.tsx
New file, marked "use client" (it ships react-markdown to the browser; CommentsShell is already a client component, line 1). It exports default function CommentMarkdown({ body }: { body: string }). Internals: import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import remarkBreaks from 'remark-breaks'; import rehypeSanitize, { defaultSchema } from 'rehype-sanitize'. Build a frozen sanitize schema once at module scope (not per-render) extending defaultSchema: keep a.target/a.rel on the allowed-attributes for 'a', and decide images (see security). Render: <div className="jh-md"><ReactMarkdown remarkPlugins={[remarkGfm, remarkBreaks]} rehypePlugins={[[rehypeSanitize, schema]]} components={{ a: ... }}>{body}</ReactMarkdown></div>. The single component override is <a>: forced target="_blank" rel="noopener noreferrer" (mirrors hypeship's link override; the rel is also re-pinned in the sanitize schema so a malicious title can't strip it). Do NOT override <code>/<pre> with Shiki (deferred); the .jh-md CSS styles them. Memoize the component body on `body` (React.memo or useMemo on the element) since the rail re-renders frequently on optimistic reaction updates (CommentsShell mutates state on every chip toggle, lines 385-406) — markdown parsing on every keystroke-adjacent re-render is wasteful.
-
3Swap render site 1 — root comment body (CommentsShell.tsx ~line 903)
Today line 902-905 is: <div style={{ color: "var(--jh-card-fg, #222)", marginTop: 2, fontFamily: "Georgia, serif", fontSize: 13, lineHeight: 1.45 }}>{t.body}{t.edited_at ? <span ...> (edited)</span> : null}</div>. Replace {t.body} with <CommentMarkdown body={t.body} />, and move the typographic intent (Georgia serif base, 13px, line-height 1.45, --jh-card-fg color) OUT of the inline style and INTO the .jh-md class so descendant elements (headings, code, blockquote) inherit and can be individually tuned — inline styles can't target descendants. Keep the (edited) marker as a sibling AFTER the markdown div (it's chrome, not body content), so it is not parsed as markdown. The wrapping flex layout (line 892 onward), avatar, author, badges are untouched.
-
4Swap render site 2 — reply body (CommentsShell.tsx ~line 930)
Today line 930 is: <div style={{ color: "var(--jh-card-fg, #222)", fontFamily: "Georgia, serif", fontSize: 13, lineHeight: 1.45 }}>{r.body}</div>. Replace {r.body} with <CommentMarkdown body={r.body} /> inside the same div, or replace the div's className with jh-md and drop the inline font styling (same rationale as site 1). Replies are 1-level (the reply fixture /home/agent/jh-design/sample-reply.md is short with inline code), but use the identical component — no second code path. Import CommentMarkdown at the top of CommentsShell.tsx alongside the existing imports (line 3-4 area).
-
5Add the .jh-md scoped styles to the injected <style> block
The file already injects component CSS via <style>{RAIL_CSS}</style> at line 481, with RAIL_CSS defined at line 1066. Add a sibling JH_MD_CSS constant (or append to RAIL_CSS) holding all .jh-md descendant rules, and render it once. This is the only way to express descendant selectors (h1-h3, ul/ol/li, code, pre, blockquote, table, a) — inline React styles cannot. See the styling field for the exact rule set. Because .jh-md lives in the same shell subtree that already sets data-theme and the --jh-card-* custom properties (line 480, 644-654), the rules just read those vars and work in both themes with zero JS.
-
6Verify no regression to the optimistic reaction / anchor postMessage flow
CommentMarkdown is a pure presentational leaf inside <Card>. It does not touch threads/reactions state, the jh:* postMessage protocol (lines 173-298), the optimistic reactAnchored mutation (367-424), or the no-overlap card layout that measures offsetHeight/offsetTop after paint (RailCards effect, 775-795). Note one real interaction: markdown bodies are TALLER and variable-height vs single-line plain text, so the post-paint re-clamp loop (line 781) must run after markdown lays out. It already runs in a useEffect keyed on [threads, positions] AFTER render, so taller cards are measured correctly on the next layout pass — but call this out for the implementer to eyeball, since a code block or table changes card height materially.
-
7Add the unit + e2e tests
See testing field. New file lib/docs/comments/CommentMarkdown.test.tsx (vitest). It needs a DOM or static-render path: the repo's vitest runs in the default node environment (vitest.config.ts only aliases @/*, no jsdom), and existing tests (lib/docs/theme.test.ts, schemas.test.ts) are pure-function. To avoid pulling in jsdom + @testing-library, render with react-dom/server renderToStaticMarkup(<CommentMarkdown body={...}/>) and assert on the returned HTML string. If a real DOM is preferred, add environment jsdom via a per-file // @vitest-environment jsdom docblock plus the jsdom devDep — but renderToStaticMarkup keeps the dep footprint at zero and still proves the sanitizer neutralizes script/javascript:.
security — the load-bearing decision
THIS IS THE LOAD-BEARING DECISION. Comment bodies are fully untrusted: anyone with comment access — any human grantee OR any agent via API key (resolveCommentPrincipal, lib/docs/comments.ts:63) — writes the raw markdown source, stored verbatim and untrimmed (createComment INSERT, comments.ts:391-405; the route stores the ORIGINAL body per schemas.ts:612-617). That body is rendered in the PARENT SHELL — the justhtml.sh origin that holds the session cookie — NOT inside the doc iframe. The doc iframe is sandbox="allow-scripts" with referrerPolicy no-referrer and an origin of 'null' (CommentsShell.tsx:508-509), so script there can't touch justhtml.sh cookies; but the comment rail is first-party DOM. Rendering untrusted markdown here is therefore a genuine stored-XSS / session-theft surface. TWO-LAYER DECISION: (1) PRIMARY — rely on react-markdown v10's safe-by-default contract: do NOT pass rehype-raw, do NOT set allowDangerousHtml. With raw HTML disabled, any literal '<script>alert(document.cookie)</script>' or '<img onerror=...>' a commenter types renders as INERT ESCAPED TEXT (the characters, not an element). v10's default urlTransform also strips dangerous URL protocols, so a '[click](javascript:alert(1))' link is neutralized to an empty/safe href. (2) DEFENSE-IN-DEPTH — ALSO add rehype-sanitize@6.0.0 with a frozen schema. hypeship omits the sanitizer and survives purely on (1), which is fine for its trusted dashboard content — but here the blast radius is account takeover, and the safety of (1) holds ONLY as long as nobody ever adds rehype-raw. rehype-sanitize makes that failure mode non-catastrophic: even if a future PR enables raw HTML, the sanitizer strips <script>, event-handler attributes, and disallowed tags at the hast layer. EXPLICIT ALLOW/STRIP MATRIX: links — ALLOWED, but every <a> is forced target="_blank" rel="noopener noreferrer" (component override) AND href is protocol-filtered (http/https/mailto only) by both urlTransform and the sanitize schema; javascript:/data: hrefs — STRIPPED. Raw/embedded HTML — NOT rendered (escaped to text). Event-handler attributes (onerror/onclick/...) — impossible (no raw HTML) and additionally stripped by the schema. Images — RECOMMENDATION: STRIP at launch (drop 'img' from the schema's allowed tags) — image markdown renders as nothing / its alt text. Rationale: remote <img src> in a session origin is a CSRF-pixel + IP/Referer-leak + layout-break vector at 320px and adds no real value to a code-review comment rail; allowing it is a one-line schema change later if a use case appears. GFM autolinks — allowed (same http/https filter). Net: links yes (with rel + protocol filter), images no, raw HTML no, javascript:/data: URLs no.
styling
Rendered markdown is styled by ONE scoped class, .jh-md, defined in a <style> block injected once (the file's established pattern: <style>{RAIL_CSS}</style> at CommentsShell.tsx:481, RAIL_CSS at line 1066). Inline React styles cannot express descendant selectors, so the per-element typography MUST live in CSS — this is the explicit reason for the <style> block over inline styles (or a CSS module, which would also work but the repo has no global CSS pipeline — no Tailwind, no PostCSS, no globals.css; confirmed — so the in-component <style> string is the idiomatic choice already used here). THEME-AWARENESS: .jh-md sits inside the shell subtree that sets data-theme="dark|light" and the --jh-card-* custom properties on the wrapper (CommentsShell.tsx:480 and paletteVars 626-681). Every rule reads those vars with the same var(--jh-x, <light-literal>) fallback pattern the rest of the file uses, so it themes for free in both light and adaptive-dark and needs zero JS: base text color: var(--jh-card-fg, #222); muted/borders: var(--jh-card-muted, #999) and var(--jh-card-line, #f1f1f1); code/pre surface: var(--jh-chip-bg, #f5f5f5) background with var(--jh-card-line) border. RULE SET (all selectors prefixed .jh-md): base — font-family Georgia, serif; font-size 13px; line-height 1.45; color var(--jh-card-fg) (moves the inline base today on lines 902/930 into the class). Tighten flow margins for a dense rail: .jh-md > *:first-child{margin-top:0} .jh-md > *:last-child{margin-bottom:0} .jh-md p{margin:.4em 0}. Headings CLAMPED so a stray # in a 320px card doesn't blow out: h1{font-size:1.25em} h2{font-size:1.12em} h3{font-size:1em}; all bold, margin .5em 0 .25em. Lists: ul/ol{padding-left:1.3em;margin:.35em 0} li{margin:.15em 0} — nested ul (present in sample-long.md item 4) inherits. Inline code: font-family ui-monospace,...,monospace (reuse the file's MONO, line 32); font-size .9em; padding 0 .3em; background var(--jh-chip-bg,#f5f5f5); border-radius 3px; overflow-wrap:anywhere + word-break:break-word so a long `identifier` WRAPS instead of widening the 320px card. Code blocks: pre{overflow-x:auto; max-width:100%; padding:8px 10px; margin:.5em 0; background var(--jh-chip-bg); border:1px solid var(--jh-card-line); border-radius:6px; font-size:12px; line-height:1.4} pre code{white-space:pre; word-break:normal} — horizontal scroll, never wraps a code line, never widens the card (the SQL fence in sample-long.md). Blockquote: border-left:3px solid var(--jh-card-line); margin:.5em 0; padding:.1em .7em; color var(--jh-card-muted) (the '> Net:' line in the fixture). Tables (GFM): display:block; overflow-x:auto; max-width:100%; border-collapse:collapse; font-size:12px; th/td{border:1px solid var(--jh-card-line); padding:3px 6px} — wrapping a block table in overflow-x:auto is what keeps a wide table scrollable inside 320px. Links: color inherits the card accent / underline; the target/rel come from the component override. img (if ever allowed) max-width:100%;height:auto. Add a CHROME_TRANSITION-consistent note: these are static colors via vars, so the existing chrome fade (line 1099) already applies when the theme refines.
edge cases
- Incomplete / partial markdown: a commenter may post an unterminated ```fence, a lone **bold, or a half-written [link. react-markdown (CommonMark/GFM) is tolerant — it renders the best-effort tree and never throws, so a partial fence just renders the rest as code text. No guard needed, but the unit test should include a ragged-markdown case to lock this behavior.
- Huge code blocks: the body cap is MAX_COMMENT_BODY_BYTES = 10*1024 (10KB, comments.ts:43), enforced as a UTF-8 byte check in the route (schemas.ts:547-549 notes the 413 stays route-side). So a single block is bounded at ~10KB — small enough to render synchronously. The .jh-md pre{overflow-x:auto} + max-width:100% keeps even a 10KB no-newline blob from widening the 320px rail; it scrolls horizontally instead.
- Images: STRIP at launch (drop img from the sanitize schema allowed tags). Image markdown renders as nothing/alt text. Avoids remote pixels in the session origin (CSRF/IP-leak) and layout breakage at 320px. Re-allow later via a one-line schema change if a real need appears.
- Link target/rel: every <a> forced target=_blank rel="noopener noreferrer" via the component override AND the rel pinned in the sanitize schema; href protocol-filtered to http/https/mailto. Matches hypeship's link override and the file's existing rel="noopener" convention (e.g. the Sign-in link, CommentsShell.tsx:586).
- GFM tables at 320px: wrapped in overflow-x:auto (display:block on table) so a wide table scrolls horizontally inside the narrow rail rather than overflowing the card or forcing the whole rail wider.
- 10KB cap interaction: unchanged — the cap is validated server-side on the raw markdown source (bytes), independent of rendering. Rendering never alters stored bytes; markdown is parsed from the stored source on every view.
- Preserving case / exact source: the body is stored ORIGINAL and untrimmed (comments.ts createComment, schemas.ts:612-617). CommentMarkdown is render-only and never mutates or normalizes the stored value — editing still round-trips the exact source. Leading/trailing whitespace in the source is handled by markdown block parsing, not trimmed away from storage.
- Optimistic reaction / anchor postMessage flow: untouched. CommentMarkdown is a presentational leaf; it does not read or write threads/reactions/positions state, does not participate in the jh:* protocol, and does not change the Card's data-no-pin click semantics (the markdown div is inside the pin-toggle area, same as the plain text it replaces). The only second-order effect is card HEIGHT: markdown is taller/variable, so the post-paint no-overlap re-clamp (RailCards effect, CommentsShell.tsx:775-795, keyed on [threads, positions]) must measure after markdown lays out — it already runs in a post-render effect, so this works, but verify a code-block card visually.
- Empty-after-render bodies: body is a required non-empty string (refine, schemas.ts:613-617), but a body of only markdown punctuation could render to near-nothing. Acceptable — same as today's plain text; no special-casing.
- Links that are bare URLs: GFM autolink turns them into anchors; they inherit the forced target/rel + protocol filter. No raw-URL XSS path because raw HTML is off.
testing
UNIT (vitest): new file lib/docs/comments/CommentMarkdown.test.tsx. The repo's vitest runs in the default NODE environment (vitest.config.ts only sets the @/* alias; existing tests theme.test.ts / schemas.test.ts are pure functions), so to test a React component WITHOUT adding jsdom, render to a string via react-dom/server's renderToStaticMarkup(<CommentMarkdown body={src} />) and assert on the HTML. Cases: (1) renders GFM — feed the sample-long.md fixture content (bold, inline code, ordered+nested list, fenced SQL, blockquote) and assert the output contains <strong>, <code>, <ul>/<ol>, <pre>, <blockquote> and the SQL text; (2) SECURITY — feed '<script>alert(document.cookie)</script>' and assert the output contains NO literal <script> element (it is escaped to <script> text) and no executable markup; (3) SECURITY — feed '[x](javascript:alert(1))' and assert the rendered <a> has NO javascript: href (href absent or sanitized); (4) link hardening — feed '[k](https://example.com)' and assert the <a> carries target="_blank" and rel containing noopener and noreferrer; (5) images — feed '' and assert NO <img> in output (stripped). Run with `npm test` (vitest run, package.json:15). If a live DOM is later wanted, add a `// @vitest-environment jsdom` docblock + jsdom devDep and switch to @testing-library/react render — but renderToStaticMarkup keeps deps at zero. E2E: scripts/e2e.ts is a black-box HTTP smoke test against a real BASE_URL using a throwaway AgentMail inbox (no app backdoor); it already exercises the comments API. Extend it minimally: after it creates a comment, POST a body containing markdown + a <script> payload, GET the comment back, and assert the API stores/returns the RAW source verbatim (rendering is client-side, so the wire body must be unchanged — this proves no server-side mangling). Visual rendering itself is best verified in the existing design/ demo harness or a Vercel preview deploy (auto-deploy/preview URLs per AGENTS.md), not the headless e2e. Run with `npm run e2e`.
risks
- Bundle weight: react-markdown + remark-gfm + the mdast/hast pipeline add ~50-70KB gzipped to an already-heavy 'use client' route (CommentsShell). Acceptable for the value, but worth measuring with `next build` output; if it regresses, the renderer could be lazy-loaded (next/dynamic) since the rail is often collapsed at load (railOpen defaults false, CommentsShell.tsx:455).
- Deferring Shiki means code blocks are unhighlighted monospace. The sample fixture has a SQL block, so reviewers will see plain code. If syntax highlighting is later wanted, add it as a <code>/<pre> override mirroring hypeship — but keep it lazy/async so it never blocks the rail render.
- Schema-as-allowlist drift: rehype-sanitize's defaultSchema must be extended carefully (a/target, a/rel allowed; img dropped). A too-aggressive schema could strip GFM table/strikethrough nodes; a too-loose one re-opens risk. Freeze the schema at module scope and cover it with the unit tests above so changes are caught.
- react-markdown v10 + React 19: v10 supports React 19, and the repo is on 19.1.0, so no peer override is expected — but confirm `npm install` resolves cleanly (no node_modules is checked in; CI/Vercel installs fresh).
- Card height / layout: markdown bodies are taller and variable, which interacts with the no-overlap re-clamp measuring offsetHeight (CommentsShell.tsx:781-792). The clamp already runs post-paint so it should self-correct, but a tall code-block card could visibly reflow once on first layout — verify on a preview deploy.
- Open question: should comment EDIT (PATCH body) get a live markdown preview in the composer? Out of scope for this change (composer textareas stay raw, CommentsShell.tsx:962/1000), but worth flagging as a follow-up since users now write markdown they can't preview before submit.
- Open question: do we want a max rendered height with a 'show more' for very tall comments in the rail? Not blocking; the 10KB cap bounds worst case and pre/table scroll, but a 10KB list could still be a long card.