Render comment bodies as Markdown in the comment rail#1
Conversation
Comment bodies were dumped as plain text, so structured reviews (numbered points, inline code, emphasis) collapsed into one wall of text. Add a shared CommentMarkdown component (react-markdown + remark-gfm + remark-breaks) and swap it into the root and reply render sites in CommentsShell. Bodies are untrusted and the rail renders in the shell origin (not the sandboxed doc iframe), so rehype-sanitize is layered on react-markdown v10's safe defaults, images are stripped, and links are forced to target=_blank rel=noopener noreferrer. Styling is a scoped .jh-md class reading the existing --jh-card-* vars so it themes light + adaptive-dark and keeps wide code/tables inside the ~320px rail. Shiki is deferred to avoid the client bundle cost. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
🤖 Pulumi Neo didn't review this pull request: no Pulumi preview ran for it. Agentic reviews require a preview of the affected stacks (for example from your CI's |
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Firetiger deploy monitoring skipped This PR didn't match the auto-monitor filter configured on your GitHub connection:
Reason: PR is for the To monitor this PR anyway, reply with |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.
Autofix Details
Bugbot Autofix prepared fixes for both issues found in the latest run.
- ✅ Fixed: Markdown clicks toggle thread pin
- I updated the card click guard to ignore clicks originating from interactive elements like links and inputs so markdown interactions no longer trigger pin toggles.
- ✅ Fixed: Comment body loses Georgia serif
- I restored the serif comment prose by adding
font-family: Georgia, serifto the.jh-mdmarkdown root style.
- I restored the serif comment prose by adding
Or push these changes by commenting:
@cursor push 67ebf51279
Preview (67ebf51279)
diff --git a/app/d/[slug]/CommentsShell.tsx b/app/d/[slug]/CommentsShell.tsx
--- a/app/d/[slug]/CommentsShell.tsx
+++ b/app/d/[slug]/CommentsShell.tsx
@@ -871,7 +871,7 @@
onMouseEnter={onHoverIn}
onMouseLeave={onHoverOut}
onClick={(e) => {
- if ((e.target as HTMLElement).closest("[data-no-pin]")) return;
+ if ((e.target as HTMLElement).closest("[data-no-pin], a, button, input, select, textarea, summary, label")) return;
onPin();
}}
style={{
@@ -1101,7 +1101,7 @@
// dark, falling back to the light literals) so it themes both ways, and pre/table
// scroll so wide code never widens the ~320px rail.
const JH_MD_CSS = `
-.jh-md { font-size: 13px; line-height: 1.5; color: var(--jh-card-fg, #222); overflow-wrap: anywhere; }
+.jh-md { font-family: Georgia, serif; font-size: 13px; line-height: 1.5; color: var(--jh-card-fg, #222); overflow-wrap: anywhere; }
.jh-md > :first-child { margin-top: 0; }
.jh-md > :last-child { margin-bottom: 0; }
.jh-md p { margin: 0 0 8px; }You can send follow-ups to the cloud agent here.
Removing the inline Georgia style from the render sites left .jh-md with no font-family, so the body inherited the rail's monospace chrome instead of the intended serif. Pin Georgia on .jh-md so prose reads as serif while code keeps the mono chips/slab (verified in light + dark). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Comment bodies render inside a click-to-pin card whose onClick pins unless the target is inside [data-no-pin]. Rendered markdown links weren't marked, so clicking a link also toggled the thread's pin/focus. Mark links data-no-pin (matching the file's existing pattern for reactions/action rows) so a link click only navigates. Found by Bugbot. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
Thanks Bugbot — both findings addressed:
|
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 4 potential issues.
Autofix Details
Bugbot Autofix prepared fixes for all 4 issues found in the latest run.
- ✅ Fixed: Fragment links open new tab
- The markdown link renderer now keeps
#...fragment anchors in-page while still applying_blankhardening to non-fragment links.
- The markdown link renderer now keeps
- ✅ Fixed: Footnote IDs duplicated across comments
CommentMarkdownnow takes a per-commentidPrefixand passes it asclobberPrefixso generated footnote ids and href targets are scoped per comment instance.
- ✅ Fixed: Reply bodies lack flex shrink
- The reply content column now sets
minWidth: 0, allowing wide markdown content to shrink and scroll instead of overflowing the rail.
- The reply content column now sets
- ✅ Fixed: Edited label no longer inline
- The edited marker is now rendered inline via a
.jh-md[data-edited] p:last-child::aftersuffix so single-paragraph comments keep(edited)attached to the comment text.
- The edited marker is now rendered inline via a
Or push these changes by commenting:
@cursor push da35e77cf0
Preview (da35e77cf0)
diff --git a/app/d/[slug]/CommentsShell.tsx b/app/d/[slug]/CommentsShell.tsx
--- a/app/d/[slug]/CommentsShell.tsx
+++ b/app/d/[slug]/CommentsShell.tsx
@@ -902,8 +902,7 @@
{t.resolved ? <Badge kind="res">resolved</Badge> : null}
{t.orphaned ? <Badge kind="orp">orphaned</Badge> : null}
<div style={{ marginTop: 2 }}>
- <CommentMarkdown body={t.body} />
- {t.edited_at ? <span style={{ color: "var(--jh-card-faint, #aaa)", fontSize: 10.5 }}> (edited)</span> : null}
+ <CommentMarkdown body={t.body} idPrefix={`comment-${t.id}`} edited={!!t.edited_at} />
</div>
<Reactions reactions={t.reactions} canComment={canComment} onReact={onReact} />
</div>
@@ -926,10 +925,10 @@
// eslint-disable-next-line @next/next/no-img-element
<img src={r.author_avatar} alt="" width={20} height={20} style={{ borderRadius: "50%", boxShadow: "0 0 0 1px var(--jh-avatar-ring, transparent)" }} />
) : null}
- <div>
+ <div style={{ minWidth: 0 }}>
<span style={{ fontWeight: 700, color: "var(--jh-card-fg, #222)" }}>{r.author ?? "someone"}</span>{" "}
<span style={{ color: "var(--jh-card-muted, #999)", fontSize: 10.5 }}>{fmtTime(r.created_at)}</span>
- <CommentMarkdown body={r.body} />
+ <CommentMarkdown body={r.body} idPrefix={`comment-${r.id}`} edited={!!r.edited_at} />
</div>
</div>
))}
@@ -1105,6 +1104,7 @@
.jh-md > :first-child { margin-top: 0; }
.jh-md > :last-child { margin-bottom: 0; }
.jh-md p { margin: 0 0 8px; }
+.jh-md[data-edited="true"] > p:last-child::after { content: " (edited)"; color: var(--jh-card-faint, #aaa); font-size: 10.5px; }
.jh-md strong { font-weight: 700; }
.jh-md em { font-style: italic; }
.jh-md a { color: inherit; text-decoration: underline; text-underline-offset: 2px; }
diff --git a/lib/docs/comments/CommentMarkdown.test.tsx b/lib/docs/comments/CommentMarkdown.test.tsx
--- a/lib/docs/comments/CommentMarkdown.test.tsx
+++ b/lib/docs/comments/CommentMarkdown.test.tsx
@@ -4,7 +4,8 @@
// renderToStaticMarkup keeps this a pure string assertion (no DOM/jsdom needed),
// matching the repo's node-env vitest setup.
-const render = (body: string) => renderToStaticMarkup(<CommentMarkdown body={body} />);
+const render = (body: string, idPrefix = "comment-test") =>
+ renderToStaticMarkup(<CommentMarkdown body={body} idPrefix={idPrefix} />);
describe("CommentMarkdown", () => {
it("renders GFM structure", () => {
@@ -43,6 +44,19 @@
expect(html).toContain("data-no-pin");
});
+ it("keeps fragment links in-page", () => {
+ const html = render("[footnote](#user-content-fn-1)");
+ expect(html).toContain('href="#user-content-fn-1"');
+ expect(html).not.toContain('target="_blank"');
+ });
+
+ it("scopes generated footnote ids by prefix", () => {
+ const htmlA = render("a[^1]\n\n[^1]: one", "comment-a");
+ const htmlB = render("b[^1]\n\n[^1]: two", "comment-b");
+ expect(htmlA).toContain('id="user-content-comment-a-fn-1"');
+ expect(htmlB).toContain('id="user-content-comment-b-fn-1"');
+ });
+
it("strips images", () => {
const html = render("");
expect(html).not.toContain("<img");
diff --git a/lib/docs/comments/CommentMarkdown.tsx b/lib/docs/comments/CommentMarkdown.tsx
--- a/lib/docs/comments/CommentMarkdown.tsx
+++ b/lib/docs/comments/CommentMarkdown.tsx
@@ -1,7 +1,7 @@
"use client";
-import { memo, type ReactNode } from "react";
-import ReactMarkdown from "react-markdown";
+import { memo, type ComponentProps } from "react";
+import ReactMarkdown, { type Components } from "react-markdown";
import remarkGfm from "remark-gfm";
import remarkBreaks from "remark-breaks";
import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
@@ -22,21 +22,39 @@
},
};
-const components = {
+const components: Components = {
// data-no-pin: comment bodies render inside a click-to-pin card (see CommentsShell
// Card onClick); without it, clicking a link would also toggle the thread's pin.
- a: ({ href, children }: { href?: string; children?: ReactNode }) => (
- <a href={href} target="_blank" rel="noopener noreferrer" data-no-pin>
- {children}
- </a>
- ),
+ a: ({ href, children, node: _node, ...props }: ComponentProps<"a"> & { node?: unknown }) => {
+ const isFragment = typeof href === "string" && href.startsWith("#");
+ return (
+ <a
+ {...props}
+ href={href}
+ target={isFragment ? undefined : "_blank"}
+ rel={isFragment ? undefined : "noopener noreferrer"}
+ data-no-pin
+ >
+ {children}
+ </a>
+ );
+ },
};
-function CommentMarkdown({ body }: { body: string }) {
+function CommentMarkdown({
+ body,
+ idPrefix,
+ edited = false,
+}: {
+ body: string;
+ idPrefix: string;
+ edited?: boolean;
+}) {
return (
- <div className="jh-md">
+ <div className="jh-md" data-edited={edited ? "true" : undefined}>
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkBreaks]}
+ remarkRehypeOptions={{ clobberPrefix: `${idPrefix}-` }}
rehypePlugins={[[rehypeSanitize, schema]]}
components={components}
>You can send follow-ups to the cloud agent here.
- Fragment (#…) links no longer force target=_blank, so footnote back-refs and in-comment hash links navigate within the card instead of opening a tab. - Namespace footnote ids per comment (useId clobberPrefix) so multiple cards in one shell DOM don't collide; clobber emptied since the only ids are the remark-generated footnote ids (raw HTML is off), keeping ref↔def matched. - Add minWidth:0 to the reply text column so wide code/tables shrink and scroll inside the rail, matching the root comment column. - Move the (edited) marker into the meta row beside the timestamp; after the swap to a block-level markdown body it no longer sits inline after the text. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
Second Bugbot pass (4 findings) addressed in 963571e:
89 tests pass, build green. |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.
Autofix Details
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Footnote back links broken
- Updated the custom markdown anchor renderer to forward all anchor props (including footnote reference ids) and added a test asserting fnref ids are preserved and targeted by back-links.
Or push these changes by commenting:
@cursor push c0693bd7ed
Preview (c0693bd7ed)
diff --git a/lib/docs/comments/CommentMarkdown.test.tsx b/lib/docs/comments/CommentMarkdown.test.tsx
--- a/lib/docs/comments/CommentMarkdown.test.tsx
+++ b/lib/docs/comments/CommentMarkdown.test.tsx
@@ -73,5 +73,8 @@
const id = one.match(/id="([^"]*fn-1)"/)?.[1];
expect(id).toBeTruthy();
expect(one).toContain(`href="#${id}"`);
+ const refId = one.match(/id="([^"]*fnref-1)"/)?.[1];
+ expect(refId).toBeTruthy();
+ expect(one).toContain(`href="#${refId}"`);
});
});
diff --git a/lib/docs/comments/CommentMarkdown.tsx b/lib/docs/comments/CommentMarkdown.tsx
--- a/lib/docs/comments/CommentMarkdown.tsx
+++ b/lib/docs/comments/CommentMarkdown.tsx
@@ -1,6 +1,6 @@
"use client";
-import { memo, useId, type ReactNode } from "react";
+import { memo, useId, type ComponentProps } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import remarkBreaks from "remark-breaks";
@@ -33,10 +33,15 @@
// Card onClick); without it, clicking a link would also toggle the thread's pin.
// Only off-page links open a new tab — in-page (#fragment) links, e.g. footnote
// back-references, must navigate within the card rather than spawn a tab.
- a: ({ href, children }: { href?: string; children?: ReactNode }) => {
+ a: ({ href, children, node: _node, ...props }: ComponentProps<"a"> & { node?: unknown }) => {
const inPage = href?.startsWith("#") ?? false;
return (
- <a href={href} data-no-pin {...(inPage ? {} : { target: "_blank", rel: "noopener noreferrer" })}>
+ <a
+ {...props}
+ href={href}
+ data-no-pin
+ {...(inPage ? {} : { target: "_blank", rel: "noopener noreferrer" })}
+ >
{children}
</a>
);You can send follow-ups to the cloud agent here.
The link override only forwarded href/children, dropping the id on footnote reference anchors, so the ↩ return link had no target. Forward the rest of the sanitized attributes (id, aria-*, data-footnote-*) instead. Also strip the useId prefix to ASCII — React 19's useId returns non-alphanumeric delimiters («…»), which were leaking into the generated ids/hrefs. Footnotes now jump forward and back, with ids unique per card. Found by Bugbot. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Task checkbox clicks toggle pin
- Added a markdown
lirenderer that tags GFM task-list rows withdata-no-pin, so checkbox and row clicks no longer bubble into the card pin toggle handler.
- Added a markdown
Or push these changes by commenting:
@cursor push d0b9ded9ee
Preview (d0b9ded9ee)
diff --git a/lib/docs/comments/CommentMarkdown.tsx b/lib/docs/comments/CommentMarkdown.tsx
--- a/lib/docs/comments/CommentMarkdown.tsx
+++ b/lib/docs/comments/CommentMarkdown.tsx
@@ -43,6 +43,16 @@
</a>
);
},
+ // Task-list rows (remark-gfm) can contain disabled checkboxes; clicks often land on
+ // the row instead of the input, so mark the whole row as no-pin.
+ li: ({ node, className, children, ...rest }: ComponentPropsWithoutRef<"li"> & ExtraProps) => {
+ const isTaskItem = className?.includes("task-list-item") ?? false;
+ return (
+ <li {...rest} className={className} {...(isTaskItem ? { "data-no-pin": true } : {})}>
+ {children}
+ </li>
+ );
+ },
};
function CommentMarkdown({ body }: { body: string }) {You can send follow-ups to the cloud agent here.
Reviewed by Cursor Bugbot for commit 386ab64. Configure here.
| </a> | ||
| ); | ||
| }, | ||
| }; |
There was a problem hiding this comment.
Task checkbox clicks toggle pin
Medium Severity
GFM task lists can render checkbox controls inside comment bodies, but only markdown links get data-no-pin. Clicks on those controls (or through disabled checkboxes onto the list row) bubble to the card’s click-to-pin handler and toggle pin/unpin instead of behaving like a checklist interaction.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 386ab64. Configure here.
There was a problem hiding this comment.
Verified this isn't reachable in practice, so leaving it as-is.
GFM task checkboxes render disabled (confirmed in the sanitized output: <input type="checkbox" disabled>), and disabled form controls don't dispatch click events — so a click on the checkbox never bubbles to the card's pin handler. Confirmed in a real browser by replicating the exact closest('[data-no-pin]') handler:
- click the disabled checkbox → 0 pin toggles
- click the row text → 1 toggle — but that's the existing body-click-to-pin behavior that applies to all comment text and predates this PR (plain-text bodies behaved identically).
The only genuinely new interactive element markdown introduces is links, which already carry data-no-pin. Happy to add data-no-pin to the checkbox as defense-in-depth if task lists ever become interactive, but as rendered (read-only/disabled) it can't toggle pin.



Summary
Comment bodies render as plain text today, so a structured review (numbered points, sub-bullets, inline code, fenced blocks, emphasis) collapses into one undifferentiated wall of text in the rail. This renders them as Markdown.
lib/docs/comments/CommentMarkdown.tsx("use client") on the same stack as the hypeship dashboard:react-markdown@10+remark-gfm@4+remark-breaks@4, swapped into both render sites inCommentsShell.tsx(root body + reply body)..jh-mdstyling injected alongsideRAIL_CSS, reading the existing--jh-card-*vars so it themes in light + adaptive-dark with zero JS, and keeps wide code/tables inside the ~320px rail (overflow-x:auto). This is the "familiar prose" design option.Security (the load-bearing bit)
Comment bodies are untrusted — any human grantee or agent can post them — and the rail renders in the shell origin (cookies/session), not the sandboxed doc iframe. So this is a stored-XSS surface. Mitigations:
javascript:URLs stripped) and layerrehype-sanitizeas defense-in-depth, so a futurerehype-rawcan't escalate a comment to account takeover;<img>in a session origin is a tracking / CSRF-pixel vector);target="_blank" rel="noopener noreferrer".Notes / decisions
<pre>. Shiki's multi-MB client grammars/themes aren't worth it for a 320px rail; easy follow-up if wanted.vitest.config.ts: addedoxc.jsx(vitest 4 transforms with oxc, and tsconfig isjsx:"preserve"for Next) so the new.tsxtest runs.Design exploration
The 5 design options + the technical plan this implements: https://justhtml.sh/d/lunar-glacier-07013
Test plan
npm test— 87 pass, incl. 6 newCommentMarkdowncases (renders GFM; neutralizes<script>; stripsjavascript:hrefs; forces target/rel; strips<img>)npx tsc --noEmitcleannpm run buildpasses (/d/[slug]First Load JS ~161 kB).env/DB creds in the clone). Worth eyeballing a markdown-heavy comment + dark mode on the preview.npm run lint(next lint) is unconfigured in this repo (drops into an interactive ESLint-setup prompt) — pre-existing, not touched here.🤖 Generated with Claude Code
Note
Medium Risk
Untrusted comment text is now parsed to HTML in the authenticated shell (stored-XSS surface); risk is mitigated by sanitize defaults, image stripping, and tests, but link/footnote edge cases still warrant review.
Overview
Comment thread and reply bodies in the comment rail are no longer shown as raw plain text; they go through a new
CommentMarkdowncomponent that renders GFM (lists, code fences, tables, footnotes, line breaks).CommentsShellwires that component in for root posts and replies, injects.jh-mdstyles (serif prose, themed via existing--jh-card-*vars, horizontal scroll for widepre/tables), and moves (edited) next to the timestamp.Security is explicit for untrusted bodies in the session shell:
rehype-sanitize(images stripped), safe link behavior (noopener,data-no-pinso links don’t toggle pin), and per-card footnote id namespacing. Addsreact-markdown/ remark / rehype deps, Vitest coverage for GFM + XSS cases, andoxc.jsxinvitest.config.tsfor the new.tsxtests.Reviewed by Cursor Bugbot for commit 386ab64. Bugbot is set up for automated code reviews on this repo. Configure here.