From 758f5e443d44b4a4e0512943a68dcaac9e511fbd Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Tue, 21 Apr 2026 07:33:33 -0700 Subject: [PATCH 1/9] =?UTF-8?q?feat(ui):=20markdown=20reader=20parity=20?= =?UTF-8?q?=E2=80=94=20HTML=20blocks,=20GitHub=20alerts,=20GFM=20inline=20?= =?UTF-8?q?extras?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brings the in-app markdown reader to parity with GitHub's flavored rendering. Additive across the parser + renderer; no behavior change for existing blocks. Refactor: - Extract InlineMarkdown (262 lines) out of Viewer.tsx into its own file - Extract BlockRenderer + block-type components (CodeBlock, HtmlBlock, AlertBlock, Callout) into components/blocks/ — Viewer drops from 1279 to ~770 lines - Each new block-level feature lands in BlockRenderer or a new blocks/*.tsx, not Viewer Block-level features: - Raw HTML blocks (
, , etc.) via balanced-tag parser branch, rendered through marked + DOMPurify for nested-markdown support; inner innerHTML set imperatively so React reconciliation doesn't collapse open
- GitHub alerts (> [!NOTE] / [!TIP] / [!WARNING] / [!CAUTION] / [!IMPORTANT]) with inline Octicons, title-case labels, GitHub's Primer colors (light + dark) - Directive containers (:::kind ... :::) with arbitrary kinds for project-specific callouts (note, tip, warning, danger, info, success, question, etc.) - Heading anchor ids — slugifyHeading() strips inline markdown, preserves unicode Inline features (all in InlineMarkdown, all code-span-safe): - Bare URL autolinks (https://...) with trailing-punctuation trimming - @mentions and #issue-refs — render as clickable links when repo is GitHub, styled spans otherwise; threaded via repoInfo.display through BlockRenderer - Emoji shortcodes (:wave:, :rocket:, 29 curated codes) via transformPlainText() - Smart punctuation (curly quotes, em/en dashes, ellipsis) applied only to plain-text fragments after code spans have been consumed Safety: - Render-time transforms live inside InlineMarkdown's plain-text push, which is only reached after code-span regex consumes code content. Backticks stay literal for shell/regex snippets. - DOMPurify allowlist (no on* handlers, no style attrs, no scripts) gates every raw HTML block. Unsafe link protocols (javascript:/data:/vbscript:/file:) stripped by sanitizeLinkUrl. Tests: +40 (149 total). New files: - utils/slugify.test.ts (10) — unicode, markdown stripping, edge cases - utils/inlineTransforms.test.ts (9) — emoji + smartypants - utils/parser.test.ts — alert detection (5 cases), directives (5 cases), HTML block balancing (5 cases) Fixtures for manual verification: - tests/test-fixtures/11-html-blocks.md - tests/test-fixtures/12-gfm-and-inline-extras.md (release-plan-shaped demo) Known limitations (not blockers): - Bare URL regex doesn't balance parens (https://en.wikipedia.org/wiki/Foo_(bar) drops the trailing ")") - Duplicate heading text → duplicate anchor ids (browser picks first on hash nav) - Directive body is inline-only (no nested headings/lists) For provenance purposes, this commit was AI assisted. --- bun.lock | 7 +- packages/ui/components/BlockRenderer.tsx | 192 +++++++ packages/ui/components/InlineMarkdown.tsx | 498 ++++++++++++++++++ packages/ui/components/Viewer.tsx | 488 +---------------- packages/ui/components/blocks/AlertBlock.tsx | 69 +++ packages/ui/components/blocks/Callout.tsx | 57 ++ packages/ui/components/blocks/CodeBlock.tsx | 75 +++ packages/ui/components/blocks/HtmlBlock.tsx | 36 ++ packages/ui/package.json | 1 + packages/ui/theme.css | 103 ++++ packages/ui/types.ts | 8 +- packages/ui/utils/inlineTransforms.test.ts | 42 ++ packages/ui/utils/inlineTransforms.ts | 36 ++ packages/ui/utils/parser.test.ts | 194 +++++++ packages/ui/utils/parser.ts | 98 +++- packages/ui/utils/sanitizeHtml.ts | 28 + packages/ui/utils/slugify.test.ts | 44 ++ packages/ui/utils/slugify.ts | 14 + tests/test-fixtures/11-html-blocks.md | 100 ++++ .../test-fixtures/12-gfm-and-inline-extras.md | 232 ++++++++ 20 files changed, 1832 insertions(+), 490 deletions(-) create mode 100644 packages/ui/components/BlockRenderer.tsx create mode 100644 packages/ui/components/InlineMarkdown.tsx create mode 100644 packages/ui/components/blocks/AlertBlock.tsx create mode 100644 packages/ui/components/blocks/Callout.tsx create mode 100644 packages/ui/components/blocks/CodeBlock.tsx create mode 100644 packages/ui/components/blocks/HtmlBlock.tsx create mode 100644 packages/ui/utils/inlineTransforms.test.ts create mode 100644 packages/ui/utils/inlineTransforms.ts create mode 100644 packages/ui/utils/sanitizeHtml.ts create mode 100644 packages/ui/utils/slugify.test.ts create mode 100644 packages/ui/utils/slugify.ts create mode 100644 tests/test-fixtures/11-html-blocks.md create mode 100644 tests/test-fixtures/12-gfm-and-inline-extras.md diff --git a/bun.lock b/bun.lock index e6b79b0f..e190b90f 100644 --- a/bun.lock +++ b/bun.lock @@ -64,7 +64,7 @@ }, "apps/opencode-plugin": { "name": "@plannotator/opencode", - "version": "0.17.10", + "version": "0.18.0", "dependencies": { "@opencode-ai/plugin": "^1.1.10", }, @@ -86,7 +86,7 @@ }, "apps/pi-extension": { "name": "@plannotator/pi-extension", - "version": "0.17.10", + "version": "0.18.0", "dependencies": { "@joplin/turndown-plugin-gfm": "^1.0.64", "turndown": "^7.2.4", @@ -182,7 +182,7 @@ }, "packages/server": { "name": "@plannotator/server", - "version": "0.17.10", + "version": "0.18.0", "dependencies": { "@plannotator/ai": "workspace:*", "@plannotator/shared": "workspace:*", @@ -209,6 +209,7 @@ "@viz-js/viz": "^3.25.0", "diff": "^8.0.3", "highlight.js": "^11.11.1", + "marked": "^17.0.6", "mermaid": "^11.12.2", "overlayscrollbars": "^2.11.0", "overlayscrollbars-react": "^0.5.6", diff --git a/packages/ui/components/BlockRenderer.tsx b/packages/ui/components/BlockRenderer.tsx new file mode 100644 index 00000000..cda559e5 --- /dev/null +++ b/packages/ui/components/BlockRenderer.tsx @@ -0,0 +1,192 @@ +import React from "react"; +import { Block } from "../types"; +import { slugifyHeading } from "../utils/slugify"; +import { InlineMarkdown } from "./InlineMarkdown"; +import { ListMarker } from "./ListMarker"; +import { CodeBlock } from "./blocks/CodeBlock"; +import { HtmlBlock } from "./blocks/HtmlBlock"; +import { Callout } from "./blocks/Callout"; +import { AlertBlock } from "./blocks/AlertBlock"; + +const parseTableContent = ( + content: string, +): { headers: string[]; rows: string[][] } => { + const lines = content.split("\n").filter((line) => line.trim()); + if (lines.length === 0) return { headers: [], rows: [] }; + + const parseRow = (line: string): string[] => { + // Remove leading/trailing pipes, split by unescaped |, then unescape \| + return line + .replace(/^\|/, "") + .replace(/\|$/, "") + .split(/(? cell.trim().replace(/\\\|/g, "|")); + }; + + const headers = parseRow(lines[0]); + const rows: string[][] = []; + + // Skip the separator line (contains dashes) and parse data rows + for (let i = 1; i < lines.length; i++) { + const line = lines[i].trim(); + // Skip separator lines (contain only dashes, pipes, colons, spaces) + if (/^[\|\-:\s]+$/.test(line)) continue; + rows.push(parseRow(line)); + } + + return { headers, rows }; +}; + +export const BlockRenderer: React.FC<{ + block: Block; + onOpenLinkedDoc?: (path: string) => void; + imageBaseDir?: string; + onImageClick?: (src: string, alt: string) => void; + onToggleCheckbox?: (blockId: string, checked: boolean) => void; + checkboxOverrides?: Map; + orderedIndex?: number | null; + githubRepo?: string; +}> = ({ block, onOpenLinkedDoc, imageBaseDir, onImageClick, onToggleCheckbox, checkboxOverrides, orderedIndex, githubRepo }) => { + switch (block.type) { + case 'heading': { + const Tag = `h${block.level || 1}` as React.ElementType; + const styles = { + 1: 'text-2xl font-bold mb-4 mt-6 first:mt-0 tracking-tight', + 2: 'text-xl font-semibold mb-3 mt-8 text-foreground/90', + 3: 'text-base font-semibold mb-2 mt-6 text-foreground/80', + }[block.level || 1] || 'text-base font-semibold mb-2 mt-4'; + const anchorId = slugifyHeading(block.content) || undefined; + + return ; + } + + case 'blockquote': { + if (block.alertKind) { + return ( + + ); + } + // Content may span multiple merged `>` lines. Split on blank-line + // paragraph breaks so `> a\n>\n> b` renders as two

children. + const paragraphs = block.content.split(/\n\n+/); + return ( +

+ {paragraphs.map((para, i) => ( +

0 ? 'mt-2' : ''}> + +

+ ))} +
+ ); + } + + case 'list-item': { + const indent = (block.level || 0) * 1.25; // 1.25rem per level + const isCheckbox = block.checked !== undefined; + const isChecked = checkboxOverrides?.has(block.id) + ? checkboxOverrides.get(block.id)! + : block.checked; + const isInteractive = isCheckbox && !!onToggleCheckbox; + return ( +
+ onToggleCheckbox!(block.id, !isChecked) : undefined} + /> + + + +
+ ); + } + + case 'code': + return {}} onLeave={() => {}} isHovered={false} />; + + case 'table': { + const { headers, rows } = parseTableContent(block.content); + return ( +
+ + + + {headers.map((header, i) => ( + + ))} + + + + {rows.map((row, rowIdx) => ( + + {row.map((cell, cellIdx) => ( + + ))} + + ))} + +
+ +
+ +
+
+ ); + } + + case 'hr': + return
; + + case 'html': + return ; + + case 'directive': { + const kind = block.directiveKind || 'note'; + return ( + + ); + } + + default: + return ( +

+ +

+ ); + } +}; diff --git a/packages/ui/components/InlineMarkdown.tsx b/packages/ui/components/InlineMarkdown.tsx new file mode 100644 index 00000000..a68a177d --- /dev/null +++ b/packages/ui/components/InlineMarkdown.tsx @@ -0,0 +1,498 @@ +import React from "react"; +import { transformPlainText } from "../utils/inlineTransforms"; + +const DANGEROUS_PROTOCOL = /^\s*(javascript|data|vbscript|file)\s*:/i; +function sanitizeLinkUrl(url: string): string | null { + if (DANGEROUS_PROTOCOL.test(url)) return null; + return url; +} + +/** + * Scanner that walks a text string and emits React nodes for inline markdown: + * emphasis (**bold**, *italic*, _italic_, ***both***), `code`, ~~strikethrough~~, + * [label](url) / ![alt](src) / , bare https:// URLs, [[wiki-links]], + * hex color swatches (#fff / #123abc), @mentions, #issue-refs, and backslash + * escapes. Plain-text chunks outside these patterns pass through + * `transformPlainText` for emoji shortcodes + smart punctuation. + */ +export const InlineMarkdown: React.FC<{ + text: string; + onOpenLinkedDoc?: (path: string) => void; + imageBaseDir?: string; + onImageClick?: (src: string, alt: string) => void; + githubRepo?: string; +}> = ({ text, onOpenLinkedDoc, imageBaseDir, onImageClick, githubRepo }) => { + const parts: React.ReactNode[] = []; + let remaining = text; + let key = 0; + let previousChar = ""; + + while (remaining.length > 0) { + // Backslash escaping: \. \* \_ \` \[ \~ etc. — emit literal char, hide backslash + let match = remaining.match(/^\\([\\*_`\[\]~!.()\-#>+|{}&])/); + if (match) { + parts.push(match[1]); + remaining = remaining.slice(2); + previousChar = match[1]; + continue; + } + + // Bare URL autolink: https://… preceded by word boundary. + // Trailing sentence punctuation is excluded so "See https://x.com." renders the period outside the link. + if (!/\w/.test(previousChar)) { + const bareMatch = remaining.match(/^https?:\/\/[^\s<>\]"']+/); + if (bareMatch) { + let url = bareMatch[0]; + while (url.length > 0 && /[.,;:!?)\]}>"']/.test(url[url.length - 1])) { + url = url.slice(0, -1); + } + const safe = url.length > 0 ? sanitizeLinkUrl(url) : null; + if (safe) { + parts.push( + + {url} + , + ); + remaining = remaining.slice(url.length); + previousChar = url[url.length - 1]; + continue; + } + } + } + + // Autolinks: or + match = remaining.match(/^<(https?:\/\/[^>]+)>/); + if (match) { + const url = match[1]; + parts.push( + + {url} + , + ); + remaining = remaining.slice(match[0].length); + previousChar = ">"; + continue; + } + match = remaining.match(/^<([^@>\s]+@[^>\s]+)>/); + if (match) { + const email = match[1]; + parts.push( + + {email} + , + ); + remaining = remaining.slice(match[0].length); + previousChar = ">"; + continue; + } + + // Strikethrough: ~~text~~ + match = remaining.match(/^~~([\s\S]+?)~~/); + if (match) { + parts.push( + + + , + ); + remaining = remaining.slice(match[0].length); + previousChar = match[0][match[0].length - 1] || previousChar; + continue; + } + + // Bold + italic: ***text*** + match = remaining.match(/^\*\*\*([\s\S]+?)\*\*\*/); + if (match) { + parts.push( + + + + + , + ); + remaining = remaining.slice(match[0].length); + previousChar = match[0][match[0].length - 1] || previousChar; + continue; + } + + // Bold: **text** ([\s\S]+? allows matching across hard line breaks) + match = remaining.match(/^\*\*([\s\S]+?)\*\*/); + if (match) { + parts.push( + + + , + ); + remaining = remaining.slice(match[0].length); + previousChar = match[0][match[0].length - 1] || previousChar; + continue; + } + + // Italic: *text* or _text_ (avoid intraword underscores) + match = remaining.match(/^\*([\s\S]+?)\*/); + if (match) { + parts.push( + + + , + ); + remaining = remaining.slice(match[0].length); + previousChar = match[0][match[0].length - 1] || previousChar; + continue; + } + + match = !/\w/.test(previousChar) + ? remaining.match(/^_([^_\s](?:[\s\S]*?[^_\s])?)_(?!\w)/) + : null; + if (match) { + parts.push( + + + , + ); + remaining = remaining.slice(match[0].length); + previousChar = match[0][match[0].length - 1] || previousChar; + continue; + } + + // Inline code: `code` + match = remaining.match(/^`([^`]+)`/); + if (match) { + parts.push( + + {match[1]} + , + ); + remaining = remaining.slice(match[0].length); + previousChar = match[0][match[0].length - 1] || previousChar; + continue; + } + + // Hex color swatch — 3/4-digit forms need an a-f letter to avoid matching issue refs like #123. + match = remaining.match( + /^(#(?:[0-9a-fA-F]{8}|[0-9a-fA-F]{6}|(?=[0-9a-fA-F]*[a-fA-F])[0-9a-fA-F]{4}|(?=[0-9a-fA-F]*[a-fA-F])[0-9a-fA-F]{3}))(?![0-9a-fA-F\w])/, + ); + if (match) { + const hex = match[1]; + parts.push( + + + + {hex} + + , + ); + remaining = remaining.slice(match[0].length); + previousChar = match[0][match[0].length - 1] || previousChar; + continue; + } + + // Issue / PR reference: #123 — only at word boundary, digits only. + // Hex-swatch branch above already consumed #fff / #123abc etc., so a bare + // #\d+ here is safe to treat as an issue ref. + if (!/\w/.test(previousChar)) { + match = remaining.match(/^#(\d+)(?!\w)/); + if (match) { + const num = match[1]; + const href = githubRepo && githubRepo.includes('/') + ? `https://github.com/${githubRepo}/issues/${num}` + : null; + const label = `#${num}`; + parts.push( + href ? ( + + {label} + + ) : ( + {label} + ), + ); + remaining = remaining.slice(match[0].length); + previousChar = match[0][match[0].length - 1]; + continue; + } + } + + // @mention — only at word boundary. GitHub-style handle pattern. + if (!/\w/.test(previousChar)) { + match = remaining.match(/^@([a-zA-Z][a-zA-Z0-9_-]{0,38})(?!\w)/); + if (match) { + const handle = match[1]; + const href = githubRepo && githubRepo.includes('/') + ? `https://github.com/${handle}` + : null; + const label = `@${handle}`; + parts.push( + href ? ( + + {label} + + ) : ( + {label} + ), + ); + remaining = remaining.slice(match[0].length); + previousChar = match[0][match[0].length - 1]; + continue; + } + } + + // Wikilinks: [[filename]] or [[filename|display text]] + match = remaining.match(/^\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/); + if (match) { + const target = match[1].trim(); + const display = match[2]?.trim() || target; + const targetPath = /\.(mdx?|html?)$/i.test(target) + ? target + : `${target}.md`; + + if (onOpenLinkedDoc) { + parts.push( + { + e.preventDefault(); + onOpenLinkedDoc(targetPath); + }} + className="text-primary underline underline-offset-2 hover:text-primary/80 inline-flex items-center gap-1 cursor-pointer" + title={`Open: ${target}`} + > + {display} + + , + ); + } else { + parts.push( + + {display} + , + ); + } + remaining = remaining.slice(match[0].length); + previousChar = match[0][match[0].length - 1] || previousChar; + continue; + } + + // Images: ![alt](path) + match = remaining.match(/^!\[([^\]]*)\]\(([^)]+)\)/); + if (match) { + const alt = match[1]; + const src = match[2]; + const imgSrc = /^https?:\/\//.test(src) + ? src + : getImageSrc(src, imageBaseDir); + parts.push( + {alt} { + e.stopPropagation(); + onImageClick?.(imgSrc, alt); + }} + />, + ); + remaining = remaining.slice(match[0].length); + previousChar = match[0][match[0].length - 1] || previousChar; + continue; + } + + // Links: [text](url) + match = remaining.match(/^\[([^\]]+)\]\(([^)]+)\)/); + if (match) { + const linkText = match[1]; + const linkUrl = match[2]; + const safeLinkUrl = sanitizeLinkUrl(linkUrl); + + // Dangerous protocol stripped — render as plain text, not a dead link + if (safeLinkUrl === null) { + parts.push({linkText}); + remaining = remaining.slice(match[0].length); + previousChar = match[0][match[0].length - 1] || previousChar; + continue; + } + + const isLocalDoc = + /\.(mdx?|html?)$/i.test(linkUrl) && + !linkUrl.startsWith("http://") && + !linkUrl.startsWith("https://"); + + if (isLocalDoc && onOpenLinkedDoc) { + parts.push( + { + e.preventDefault(); + onOpenLinkedDoc(linkUrl); + }} + className="text-primary underline underline-offset-2 hover:text-primary/80 inline-flex items-center gap-1 cursor-pointer" + title={`Open: ${linkUrl}`} + > + {linkText} + + , + ); + } else if (isLocalDoc) { + // No handler — render as plain link (e.g., in shared/portal views) + parts.push( + + {linkText} + , + ); + } else { + parts.push( + + {linkText} + , + ); + } + remaining = remaining.slice(match[0].length); + previousChar = match[0][match[0].length - 1] || previousChar; + continue; + } + + // Hard line break: two+ trailing spaces + newline, or backslash + newline + match = remaining.match(/ {2,}\n|\\\n/); + if (match && match.index !== undefined) { + const before = remaining.slice(0, match.index); + if (before) { + parts.push( + , + ); + } + parts.push(
); + remaining = remaining.slice(match.index + match[0].length); + previousChar = "\n"; + continue; + } + + // Find next special character or consume one regular character + const nextSpecial = remaining.slice(1).search(/[\*_`\[!~\\<#h@]/); + if (nextSpecial === -1) { + parts.push(transformPlainText(remaining)); + previousChar = remaining[remaining.length - 1] || previousChar; + break; + } else { + const plainText = remaining.slice(0, nextSpecial + 1); + parts.push(transformPlainText(plainText)); + remaining = remaining.slice(nextSpecial + 1); + previousChar = plainText[plainText.length - 1] || previousChar; + } + } + + return <>{parts}; +}; diff --git a/packages/ui/components/Viewer.tsx b/packages/ui/components/Viewer.tsx index 2e12dc04..79c0824e 100644 --- a/packages/ui/components/Viewer.tsx +++ b/packages/ui/components/Viewer.tsx @@ -1,9 +1,10 @@ import React, { useRef, useState, useEffect, forwardRef, useImperativeHandle, useCallback } from 'react'; import { createPortal } from 'react-dom'; import hljs from 'highlight.js'; -import 'highlight.js/styles/github-dark.css'; import { Block, Annotation, AnnotationType, EditorMode, type InputMethod, type ImageAttachment, type ActionsLabelMode } from '../types'; import { Frontmatter, computeListIndices } from '../utils/parser'; +import { BlockRenderer } from './BlockRenderer'; +import { CodeBlock } from './blocks/CodeBlock'; import { ListMarker } from './ListMarker'; import { AnnotationToolbar } from './AnnotationToolbar'; import { FloatingQuickLabelPicker } from './FloatingQuickLabelPicker'; @@ -25,6 +26,7 @@ class ToolbarErrorBoundary extends React.Component< return this.props.children; } } + import { CommentPopover } from './CommentPopover'; import { TaterSpriteSitting } from './TaterSpriteSitting'; import { AttachmentsButton } from './AttachmentsButton'; @@ -547,6 +549,7 @@ export const Viewer = forwardRef(({ onOpenLinkedDoc={onOpenLinkedDoc} onToggleCheckbox={onToggleCheckbox} checkboxOverrides={checkboxOverrides} + githubRepo={repoInfo?.display} /> ))} @@ -587,7 +590,7 @@ export const Viewer = forwardRef(({ isHovered={inputMethod !== 'pinpoint' && hoveredCodeBlock?.block.id === group.block.id} /> ) : ( - setLightbox({ src, alt })} key={group.block.id} block={group.block} onOpenLinkedDoc={onOpenLinkedDoc} onToggleCheckbox={onToggleCheckbox} checkboxOverrides={checkboxOverrides} /> + setLightbox({ src, alt })} key={group.block.id} block={group.block} onOpenLinkedDoc={onOpenLinkedDoc} onToggleCheckbox={onToggleCheckbox} checkboxOverrides={checkboxOverrides} githubRepo={repoInfo?.display} /> ) )} @@ -733,306 +736,9 @@ const ImageLightbox: React.FC<{ src: string; alt: string; onClose: () => void }> ); }; -/** Block dangerous link protocols (javascript:, data:, vbscript:, file:). Returns null for blocked URLs. */ -const DANGEROUS_PROTOCOL = /^\s*(javascript|data|vbscript|file)\s*:/i; -function sanitizeLinkUrl(url: string): string | null { - if (DANGEROUS_PROTOCOL.test(url)) return null; - return url; -} - -/** - * Renders inline markdown: **bold**, *italic*, _italic_, `code`, [links](url) - */ -const InlineMarkdown: React.FC<{ text: string; onOpenLinkedDoc?: (path: string) => void; imageBaseDir?: string; onImageClick?: (src: string, alt: string) => void }> = ({ text, onOpenLinkedDoc, imageBaseDir, onImageClick }) => { - const parts: React.ReactNode[] = []; - let remaining = text; - let key = 0; - let previousChar = ''; - - while (remaining.length > 0) { - // Backslash escaping: \. \* \_ \` \[ \~ etc. — emit literal char, hide backslash - let match = remaining.match(/^\\([\\*_`\[\]~!.()\-#>+|{}&])/); - if (match) { - parts.push(match[1]); - remaining = remaining.slice(2); - previousChar = match[1]; - continue; - } - - // Autolinks: or - match = remaining.match(/^<(https?:\/\/[^>]+)>/); - if (match) { - const url = match[1]; - parts.push({url}); - remaining = remaining.slice(match[0].length); - previousChar = '>'; - continue; - } - match = remaining.match(/^<([^@>\s]+@[^>\s]+)>/); - if (match) { - const email = match[1]; - parts.push({email}); - remaining = remaining.slice(match[0].length); - previousChar = '>'; - continue; - } - - // Strikethrough: ~~text~~ - match = remaining.match(/^~~([\s\S]+?)~~/); - if (match) { - parts.push(); - remaining = remaining.slice(match[0].length); - previousChar = match[0][match[0].length - 1] || previousChar; - continue; - } - - // Bold + italic: ***text*** - match = remaining.match(/^\*\*\*([\s\S]+?)\*\*\*/); - if (match) { - parts.push(); - remaining = remaining.slice(match[0].length); - previousChar = match[0][match[0].length - 1] || previousChar; - continue; - } - - // Bold: **text** ([\s\S]+? allows matching across hard line breaks) - match = remaining.match(/^\*\*([\s\S]+?)\*\*/); - if (match) { - parts.push(); - remaining = remaining.slice(match[0].length); - previousChar = match[0][match[0].length - 1] || previousChar; - continue; - } - - // Italic: *text* or _text_ (avoid intraword underscores) - match = remaining.match(/^\*([\s\S]+?)\*/); - if (match) { - parts.push(); - remaining = remaining.slice(match[0].length); - previousChar = match[0][match[0].length - 1] || previousChar; - continue; - } - - match = !/\w/.test(previousChar) - ? remaining.match(/^_([^_\s](?:[\s\S]*?[^_\s])?)_(?!\w)/) - : null; - if (match) { - parts.push(); - remaining = remaining.slice(match[0].length); - previousChar = match[0][match[0].length - 1] || previousChar; - continue; - } - - // Inline code: `code` - match = remaining.match(/^`([^`]+)`/); - if (match) { - parts.push( - - {match[1]} - - ); - remaining = remaining.slice(match[0].length); - previousChar = match[0][match[0].length - 1] || previousChar; - continue; - } - - // Hex color swatch — 3/4-digit forms need an a-f letter to avoid matching issue refs like #123. - match = remaining.match(/^(#(?:[0-9a-fA-F]{8}|[0-9a-fA-F]{6}|(?=[0-9a-fA-F]*[a-fA-F])[0-9a-fA-F]{4}|(?=[0-9a-fA-F]*[a-fA-F])[0-9a-fA-F]{3}))(?![0-9a-fA-F\w])/); - if (match) { - const hex = match[1]; - parts.push( - - - {hex} - - ); - remaining = remaining.slice(match[0].length); - previousChar = match[0][match[0].length - 1] || previousChar; - continue; - } - - // Wikilinks: [[filename]] or [[filename|display text]] - match = remaining.match(/^\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/); - if (match) { - const target = match[1].trim(); - const display = match[2]?.trim() || target; - const targetPath = /\.(mdx?|html?)$/i.test(target) ? target : `${target}.md`; - if (onOpenLinkedDoc) { - parts.push( - { - e.preventDefault(); - onOpenLinkedDoc(targetPath); - }} - className="text-primary underline underline-offset-2 hover:text-primary/80 inline-flex items-center gap-1 cursor-pointer" - title={`Open: ${target}`} - > - {display} - - - ); - } else { - parts.push( - {display} - ); - } - remaining = remaining.slice(match[0].length); - previousChar = match[0][match[0].length - 1] || previousChar; - continue; - } - - // Images: ![alt](path) - match = remaining.match(/^!\[([^\]]*)\]\(([^)]+)\)/); - if (match) { - const alt = match[1]; - const src = match[2]; - const imgSrc = /^https?:\/\//.test(src) ? src : getImageSrc(src, imageBaseDir); - parts.push( - {alt} { e.stopPropagation(); onImageClick?.(imgSrc, alt); }} - /> - ); - remaining = remaining.slice(match[0].length); - previousChar = match[0][match[0].length - 1] || previousChar; - continue; - } - - // Links: [text](url) - match = remaining.match(/^\[([^\]]+)\]\(([^)]+)\)/); - if (match) { - const linkText = match[1]; - const linkUrl = match[2]; - const safeLinkUrl = sanitizeLinkUrl(linkUrl); - - // Dangerous protocol stripped — render as plain text, not a dead link - if (safeLinkUrl === null) { - parts.push({linkText}); - remaining = remaining.slice(match[0].length); - previousChar = match[0][match[0].length - 1] || previousChar; - continue; - } - - const isLocalDoc = /\.(mdx?|html?)$/i.test(linkUrl) && - !linkUrl.startsWith('http://') && - !linkUrl.startsWith('https://'); - - if (isLocalDoc && onOpenLinkedDoc) { - parts.push( - { - e.preventDefault(); - onOpenLinkedDoc(linkUrl); - }} - className="text-primary underline underline-offset-2 hover:text-primary/80 inline-flex items-center gap-1 cursor-pointer" - title={`Open: ${linkUrl}`} - > - {linkText} - - - ); - } else if (isLocalDoc) { - // No handler — render as plain link (e.g., in shared/portal views) - parts.push( - - {linkText} - - ); - } else { - parts.push( - - {linkText} - - ); - } - remaining = remaining.slice(match[0].length); - previousChar = match[0][match[0].length - 1] || previousChar; - continue; - } - // Hard line break: two+ trailing spaces + newline, or backslash + newline - match = remaining.match(/ {2,}\n|\\\n/); - if (match && match.index !== undefined) { - const before = remaining.slice(0, match.index); - if (before) { - parts.push(); - } - parts.push(
); - remaining = remaining.slice(match.index + match[0].length); - previousChar = '\n'; - continue; - } - - // Find next special character or consume one regular character - const nextSpecial = remaining.slice(1).search(/[\*_`\[!~\\<#]/); - if (nextSpecial === -1) { - parts.push(remaining); - previousChar = remaining[remaining.length - 1] || previousChar; - break; - } else { - const plainText = remaining.slice(0, nextSpecial + 1); - parts.push(plainText); - remaining = remaining.slice(nextSpecial + 1); - previousChar = plainText[plainText.length - 1] || previousChar; - } - } - return <>{parts}; -}; - -const parseTableContent = (content: string): { headers: string[]; rows: string[][] } => { - const lines = content.split('\n').filter(line => line.trim()); - if (lines.length === 0) return { headers: [], rows: [] }; - - const parseRow = (line: string): string[] => { - // Remove leading/trailing pipes, split by unescaped |, then unescape \| - return line - .replace(/^\|/, '') - .replace(/\|$/, '') - .split(/(? cell.trim().replace(/\\\|/g, '|')); - }; - - const headers = parseRow(lines[0]); - const rows: string[][] = []; - - // Skip the separator line (contains dashes) and parse data rows - for (let i = 1; i < lines.length; i++) { - const line = lines[i].trim(); - // Skip separator lines (contain only dashes, pipes, colons, spaces) - if (/^[\|\-:\s]+$/.test(line)) continue; - rows.push(parseRow(line)); - } - - return { headers, rows }; -}; /** Groups consecutive list-item blocks so they can share a pinpoint hover wrapper. */ type RenderGroup = @@ -1058,190 +764,6 @@ function groupBlocks(blocks: Block[]): RenderGroup[] { return groups; } -const BlockRenderer: React.FC<{ - block: Block; - onOpenLinkedDoc?: (path: string) => void; - imageBaseDir?: string; - onImageClick?: (src: string, alt: string) => void; - onToggleCheckbox?: (blockId: string, checked: boolean) => void; - checkboxOverrides?: Map; - orderedIndex?: number | null; -}> = ({ block, onOpenLinkedDoc, imageBaseDir, onImageClick, onToggleCheckbox, checkboxOverrides, orderedIndex }) => { - switch (block.type) { - case 'heading': - const Tag = `h${block.level || 1}` as React.ElementType; - const styles = { - 1: 'text-2xl font-bold mb-4 mt-6 first:mt-0 tracking-tight', - 2: 'text-xl font-semibold mb-3 mt-8 text-foreground/90', - 3: 'text-base font-semibold mb-2 mt-6 text-foreground/80', - }[block.level || 1] || 'text-base font-semibold mb-2 mt-4'; - - return ; - case 'blockquote': { - // Content may span multiple merged `>` lines. Split on blank-line - // paragraph breaks so `> a\n>\n> b` renders as two

children. - const paragraphs = block.content.split(/\n\n+/); - return ( -

- {paragraphs.map((para, i) => ( -

0 ? 'mt-2' : ''}> - -

- ))} -
- ); - } - case 'list-item': { - const indent = (block.level || 0) * 1.25; // 1.25rem per level - const isCheckbox = block.checked !== undefined; - const isChecked = checkboxOverrides?.has(block.id) - ? checkboxOverrides.get(block.id)! - : block.checked; - const isInteractive = isCheckbox && !!onToggleCheckbox; - return ( -
- onToggleCheckbox!(block.id, !isChecked) : undefined} - /> - - - -
- ); - } - - case 'code': - return {}} onLeave={() => {}} isHovered={false} />; - - case 'table': { - const { headers, rows } = parseTableContent(block.content); - return ( -
- - - - {headers.map((header, i) => ( - - ))} - - - - {rows.map((row, rowIdx) => ( - - {row.map((cell, cellIdx) => ( - - ))} - - ))} - -
- -
- -
-
- ); - } - - case 'hr': - return
; - - default: - return ( -

- -

- ); - } -}; -interface CodeBlockProps { - block: Block; - onHover: (element: HTMLElement) => void; - onLeave: () => void; - isHovered: boolean; -} - -const CodeBlock: React.FC = ({ block, onHover, onLeave, isHovered }) => { - const [copied, setCopied] = useState(false); - const containerRef = useRef(null); - const codeRef = useRef(null); - - // Highlight code block on mount and when content/language changes - useEffect(() => { - if (codeRef.current) { - // Reset any previous highlighting - codeRef.current.removeAttribute('data-highlighted'); - codeRef.current.className = `hljs font-mono${block.language ? ` language-${block.language}` : ''}`; - hljs.highlightElement(codeRef.current); - } - }, [block.content, block.language]); - - const handleCopy = useCallback(async () => { - try { - await navigator.clipboard.writeText(block.content); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - } catch (err) { - console.error('Failed to copy:', err); - } - }, [block.content]); - - const handleMouseEnter = () => { - if (containerRef.current) { - onHover(containerRef.current); - } - }; - - // Build className for code element - const codeClassName = `hljs font-mono${block.language ? ` language-${block.language}` : ''}`; - - return ( -
- -
-        {block.content}
-      
-
- ); -}; diff --git a/packages/ui/components/blocks/AlertBlock.tsx b/packages/ui/components/blocks/AlertBlock.tsx new file mode 100644 index 00000000..996a4062 --- /dev/null +++ b/packages/ui/components/blocks/AlertBlock.tsx @@ -0,0 +1,69 @@ +import React from 'react'; +import type { AlertKind } from '../../types'; +import { InlineMarkdown } from '../InlineMarkdown'; + +interface AlertBlockProps { + blockId: string; + kind: AlertKind; + body: string; + onOpenLinkedDoc?: (path: string) => void; + imageBaseDir?: string; + onImageClick?: (src: string, alt: string) => void; + githubRepo?: string; +} + +const TITLE: Record = { + note: 'Note', + tip: 'Tip', + warning: 'Warning', + caution: 'Caution', + important: 'Important', +}; + +const Icon: React.FC<{ kind: AlertKind }> = ({ kind }) => { + const common = { viewBox: '0 0 16 16', width: '16', height: '16', fill: 'currentColor', 'aria-hidden': true as const }; + switch (kind) { + case 'note': + return ; + case 'tip': + return ; + case 'warning': + return ; + case 'caution': + return ; + case 'important': + return ; + } +}; + +export const AlertBlock: React.FC = ({ + blockId, kind, body, onOpenLinkedDoc, imageBaseDir, onImageClick, githubRepo, +}) => { + const paragraphs = body.split(/\n\n+/); + return ( +
+
+ + {TITLE[kind]} +
+ {paragraphs.map((para, i) => + para ? ( +

0 ? 'mt-2' : ''}`}> + +

+ ) : null, + )} +
+ ); +}; diff --git a/packages/ui/components/blocks/Callout.tsx b/packages/ui/components/blocks/Callout.tsx new file mode 100644 index 00000000..f6f31d30 --- /dev/null +++ b/packages/ui/components/blocks/Callout.tsx @@ -0,0 +1,57 @@ +import React from 'react'; +import { InlineMarkdown } from '../InlineMarkdown'; + +interface CalloutProps { + blockId: string; + kind: string; + body: string; + containerClassName: string; + blockType: 'alert' | 'directive'; + kindAttribute: string; + onOpenLinkedDoc?: (path: string) => void; + imageBaseDir?: string; + onImageClick?: (src: string, alt: string) => void; + githubRepo?: string; +} + +export const Callout: React.FC = ({ + blockId, + kind, + body, + containerClassName, + blockType, + kindAttribute, + onOpenLinkedDoc, + imageBaseDir, + onImageClick, + githubRepo, +}) => { + const paragraphs = body.split(/\n\n+/); + const kindAttr = + blockType === 'alert' ? { 'data-alert-kind': kindAttribute } : { 'data-directive-kind': kindAttribute }; + return ( +
+
+ {kind} +
+ {paragraphs.map((para, i) => + para ? ( +

0 ? 'mt-2' : ''}`}> + +

+ ) : null, + )} +
+ ); +}; diff --git a/packages/ui/components/blocks/CodeBlock.tsx b/packages/ui/components/blocks/CodeBlock.tsx new file mode 100644 index 00000000..b9b47570 --- /dev/null +++ b/packages/ui/components/blocks/CodeBlock.tsx @@ -0,0 +1,75 @@ +import React, { useState, useRef, useEffect, useCallback } from 'react'; +import hljs from 'highlight.js'; +import 'highlight.js/styles/github-dark.css'; +import { Block } from '../../types'; + +interface CodeBlockProps { + block: Block; + onHover: (element: HTMLElement) => void; + onLeave: () => void; + isHovered: boolean; +} + +export const CodeBlock: React.FC = ({ block, onHover, onLeave, isHovered }) => { + const [copied, setCopied] = useState(false); + const containerRef = useRef(null); + const codeRef = useRef(null); + + // Highlight code block on mount and when content/language changes + useEffect(() => { + if (codeRef.current) { + // Reset any previous highlighting + codeRef.current.removeAttribute('data-highlighted'); + codeRef.current.className = `hljs font-mono${block.language ? ` language-${block.language}` : ''}`; + hljs.highlightElement(codeRef.current); + } + }, [block.content, block.language]); + + const handleCopy = useCallback(async () => { + try { + await navigator.clipboard.writeText(block.content); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (err) { + console.error('Failed to copy:', err); + } + }, [block.content]); + + const handleMouseEnter = () => { + if (containerRef.current) { + onHover(containerRef.current); + } + }; + + // Build className for code element + const codeClassName = `hljs font-mono${block.language ? ` language-${block.language}` : ''}`; + + return ( +
+ +
+        {block.content}
+      
+
+ ); +}; \ No newline at end of file diff --git a/packages/ui/components/blocks/HtmlBlock.tsx b/packages/ui/components/blocks/HtmlBlock.tsx new file mode 100644 index 00000000..5702a1c8 --- /dev/null +++ b/packages/ui/components/blocks/HtmlBlock.tsx @@ -0,0 +1,36 @@ +import React, { useRef, useEffect } from "react"; +import { Block } from "../../types"; +import { sanitizeBlockHtml } from "../../utils/sanitizeHtml"; + +// The inner HTML is set imperatively (not via dangerouslySetInnerHTML) so that +// React's reconciliation never replaces the rendered subtree on re-render. +// That matters because
is DOM-owned state — a stray innerHTML +// re-set on every parent re-render would collapse any open
the +// user just opened. Paired with React.memo below so the component itself +// stops re-rendering unless the block content actually changes. +const HtmlBlockImpl: React.FC<{ block: Block }> = ({ block }) => { + const ref = useRef(null); + const sanitized = React.useMemo( + () => sanitizeBlockHtml(block.content), + [block.content], + ); + useEffect(() => { + if (ref.current && ref.current.innerHTML !== sanitized) { + ref.current.innerHTML = sanitized; + } + }, [sanitized]); + return ( +
+ ); +}; +export const HtmlBlock = React.memo( + HtmlBlockImpl, + (prev, next) => + prev.block.id === next.block.id && + prev.block.content === next.block.content, +); diff --git a/packages/ui/package.json b/packages/ui/package.json index f7f83331..082738f5 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -20,6 +20,7 @@ "@viz-js/viz": "^3.25.0", "diff": "^8.0.3", "highlight.js": "^11.11.1", + "marked": "^17.0.6", "mermaid": "^11.12.2", "overlayscrollbars": "^2.11.0", "overlayscrollbars-react": "^0.5.6", diff --git a/packages/ui/theme.css b/packages/ui/theme.css index 59c783b4..2a3f1a15 100644 --- a/packages/ui/theme.css +++ b/packages/ui/theme.css @@ -228,3 +228,106 @@ body { .plan-diff-word-removed code { background-color: color-mix(in oklab, var(--destructive) 20%, var(--muted)); } + +/* Raw HTML blocks (CommonMark Type 6) rendered via dangerouslySetInnerHTML. + Minimal typography so
,
, nested lists etc. inherit + sensible spacing without fighting the surrounding prose styles. */ +.html-block details { + border: 1px solid var(--border); + border-radius: 6px; + padding: 0.5rem 0.75rem; + margin: 0.5rem 0; + background-color: color-mix(in oklab, var(--muted) 30%, transparent); +} +.html-block details[open] { + padding-bottom: 0.75rem; +} +.html-block summary { + cursor: pointer; + font-weight: 500; + color: var(--foreground); + list-style: revert; +} +.html-block summary::-webkit-details-marker { + color: color-mix(in oklab, var(--foreground) 60%, transparent); +} +.html-block details[open] > summary { + margin-bottom: 0.5rem; +} +.html-block p { + margin: 0.5rem 0; +} +.html-block ul, +.html-block ol { + margin: 0.5rem 0; + padding-left: 1.5rem; +} +.html-block ul { list-style: disc; } +.html-block ol { list-style: decimal; } +.html-block blockquote { + border-left: 2px solid color-mix(in oklab, var(--primary) 50%, transparent); + padding-left: 1rem; + margin: 0.75rem 0; + color: var(--muted-foreground); + font-style: italic; +} +.html-block code { + background-color: var(--muted); + padding: 0.1em 0.35em; + border-radius: 3px; + font-size: 0.9em; +} +.html-block pre { + background-color: color-mix(in oklab, var(--muted) 50%, transparent); + border: 1px solid color-mix(in oklab, var(--border) 30%, transparent); + border-radius: 6px; + padding: 0.75rem; + overflow-x: auto; + margin: 0.75rem 0; +} +.html-block pre code { + background: transparent; + padding: 0; +} +.html-block a { + color: var(--primary); + text-decoration: underline; + text-underline-offset: 2px; +} + +/* GitHub-style alerts: > [!NOTE] / [!TIP] / [!WARNING] / [!CAUTION] / [!IMPORTANT] */ +.alert-note { border-left-color: #0969da; } +.alert-note .alert-title { color: #0969da; } +.alert-tip { border-left-color: #1a7f37; } +.alert-tip .alert-title { color: #1a7f37; } +.alert-warning { border-left-color: #9a6700; } +.alert-warning .alert-title { color: #9a6700; } +.alert-caution { border-left-color: #cf222e; } +.alert-caution .alert-title { color: #cf222e; } +.alert-important { border-left-color: #8250df; } +.alert-important .alert-title { color: #8250df; } + +@media (prefers-color-scheme: dark) { + .alert-note { border-left-color: #4493f8; } + .alert-note .alert-title { color: #4493f8; } + .alert-tip { border-left-color: #3fb950; } + .alert-tip .alert-title { color: #3fb950; } + .alert-warning { border-left-color: #d29922; } + .alert-warning .alert-title { color: #d29922; } + .alert-caution { border-left-color: #f85149; } + .alert-caution .alert-title { color: #f85149; } + .alert-important { border-left-color: #ab7df8; } + .alert-important .alert-title { color: #ab7df8; } +} + +/* Directive containers: :::kind ... ::: */ +.directive { border-color: var(--border); background: color-mix(in srgb, var(--muted) 50%, transparent); } +.directive-title { color: var(--muted-foreground); } +.directive-note, .directive-info { background: color-mix(in srgb, #3b82f6 10%, transparent); border-color: color-mix(in srgb, #3b82f6 40%, transparent); } +.directive-note .directive-title, .directive-info .directive-title { color: #3b82f6; } +.directive-tip, .directive-success { background: color-mix(in srgb, #10b981 10%, transparent); border-color: color-mix(in srgb, #10b981 40%, transparent); } +.directive-tip .directive-title, .directive-success .directive-title { color: #10b981; } +.directive-warning { background: color-mix(in srgb, #f59e0b 10%, transparent); border-color: color-mix(in srgb, #f59e0b 40%, transparent); } +.directive-warning .directive-title { color: #f59e0b; } +.directive-danger, .directive-caution { background: color-mix(in srgb, #ef4444 10%, transparent); border-color: color-mix(in srgb, #ef4444 40%, transparent); } +.directive-danger .directive-title, .directive-caution .directive-title { color: #ef4444; } diff --git a/packages/ui/types.ts b/packages/ui/types.ts index 99a7f3d9..57854242 100644 --- a/packages/ui/types.ts +++ b/packages/ui/types.ts @@ -53,15 +53,19 @@ export interface Annotation { }; } +export type AlertKind = 'note' | 'tip' | 'warning' | 'caution' | 'important'; + export interface Block { id: string; - type: 'paragraph' | 'heading' | 'blockquote' | 'list-item' | 'code' | 'hr' | 'table'; - content: string; // Plain text content + type: 'paragraph' | 'heading' | 'blockquote' | 'list-item' | 'code' | 'hr' | 'table' | 'html' | 'directive'; + content: string; // Plain text, or raw (unsanitized) HTML for type === 'html' level?: number; // For headings (1-6) or list indentation language?: string; // For code blocks (e.g., 'rust', 'typescript') checked?: boolean; // For checkbox list items (true = checked, false = unchecked, undefined = not a checkbox) ordered?: boolean; // For list items: true when source marker was \d+. orderedStart?: number; // For ordered list items: integer parsed from the marker (e.g. 5 for "5.") + alertKind?: AlertKind; // For blockquotes starting with [!NOTE] / [!TIP] / etc. + directiveKind?: string; // For directive containers (e.g. ':::note' → 'note') order: number; // Sorting order startLine: number; // 1-based line number in source } diff --git a/packages/ui/utils/inlineTransforms.test.ts b/packages/ui/utils/inlineTransforms.test.ts new file mode 100644 index 00000000..3359586b --- /dev/null +++ b/packages/ui/utils/inlineTransforms.test.ts @@ -0,0 +1,42 @@ +import { describe, test, expect } from 'bun:test'; +import { transformPlainText } from './inlineTransforms'; + +describe('transformPlainText — emoji shortcodes', () => { + test('replaces known shortcode with unicode emoji', () => { + expect(transformPlainText('hello :wave:')).toBe('hello 👋'); + }); + + test('leaves unknown shortcode untouched', () => { + expect(transformPlainText('hello :notaknownemoji:')).toBe('hello :notaknownemoji:'); + }); + + test('replaces multiple shortcodes in one string', () => { + expect(transformPlainText(':rocket: to the :star:')).toBe('🚀 to the ⭐'); + }); +}); + +describe('transformPlainText — smart punctuation', () => { + test('converts triple dots to ellipsis', () => { + expect(transformPlainText('wait...')).toBe('wait…'); + }); + + test('converts triple hyphen to em dash', () => { + expect(transformPlainText('before --- after')).toBe('before — after'); + }); + + test('converts double hyphen to en dash', () => { + expect(transformPlainText('pages 3--5')).toBe('pages 3–5'); + }); + + test('curls straight double quotes', () => { + expect(transformPlainText('she said "hello"')).toBe('she said “hello”'); + }); + + test('curls apostrophe inside a word', () => { + expect(transformPlainText("don't stop")).toBe('don’t stop'); + }); + + test('curls single quotes around a phrase', () => { + expect(transformPlainText("he said 'hi'")).toBe('he said ‘hi’'); + }); +}); diff --git a/packages/ui/utils/inlineTransforms.ts b/packages/ui/utils/inlineTransforms.ts new file mode 100644 index 00000000..e4171c2e --- /dev/null +++ b/packages/ui/utils/inlineTransforms.ts @@ -0,0 +1,36 @@ +/** + * Render-time transforms applied to plain-text fragments inside the inline + * scanner. Called only after code spans, links, and other markdown syntax + * have been consumed — so transforms here are guaranteed to operate on prose, + * not on code or URL strings. + */ + +const EMOJI_MAP: Record = { + smile: '😄', heart: '❤️', thumbsup: '👍', thumbsdown: '👎', + fire: '🔥', star: '⭐', tada: '🎉', rocket: '🚀', + bug: '🐛', sparkles: '✨', warning: '⚠️', white_check_mark: '✅', + x: '❌', eyes: '👀', wave: '👋', thinking: '🤔', + ok: '🆗', construction: '🚧', boom: '💥', gear: '⚙️', + hourglass: '⏳', zap: '⚡', lock: '🔒', unlock: '🔓', + memo: '📝', book: '📖', package: '📦', hammer: '🔨', + checkered_flag: '🏁', question: '❓', exclamation: '❗', bulb: '💡', +}; + +function replaceEmoji(s: string): string { + return s.replace(/:([a-z_]+):/g, (whole, code) => EMOJI_MAP[code] ?? whole); +} + +function smartypants(s: string): string { + return s + .replace(/\.{3}/g, '…') + .replace(/---/g, '—') + .replace(/(^|[^-])--(?!-)/g, '$1–') + .replace(/(^|[\s([{])"/g, '$1“') + .replace(/"/g, '”') + .replace(/(^|[\s([{])'/g, '$1‘') + .replace(/'/g, '’'); +} + +export function transformPlainText(text: string): string { + return smartypants(replaceEmoji(text)); +} diff --git a/packages/ui/utils/parser.test.ts b/packages/ui/utils/parser.test.ts index 74e055e2..cc5f79db 100644 --- a/packages/ui/utils/parser.test.ts +++ b/packages/ui/utils/parser.test.ts @@ -514,6 +514,200 @@ describe("parseMarkdownToBlocks — blockquotes", () => { }); }); +describe("parseMarkdownToBlocks — GitHub alerts", () => { + test("detects NOTE alert and strips marker from content", () => { + const md = "> [!NOTE]\n> Useful information."; + const blocks = parseMarkdownToBlocks(md); + expect(blocks).toHaveLength(1); + expect(blocks[0].type).toBe("blockquote"); + expect(blocks[0].alertKind).toBe("note"); + expect(blocks[0].content).toBe("Useful information."); + }); + + test("detects each GitHub alert kind, case-insensitive", () => { + for (const kind of ["NOTE", "TIP", "WARNING", "CAUTION", "IMPORTANT"]) { + const blocks = parseMarkdownToBlocks(`> [!${kind}]\n> body`); + expect(blocks[0].alertKind).toBe(kind.toLowerCase()); + } + const lower = parseMarkdownToBlocks("> [!note]\n> body"); + expect(lower[0].alertKind).toBe("note"); + }); + + test("alert marker alone (no body) still tags the block", () => { + const md = "> [!TIP]"; + const blocks = parseMarkdownToBlocks(md); + expect(blocks[0].alertKind).toBe("tip"); + expect(blocks[0].content).toBe(""); + }); + + test("blockquote without marker has no alertKind", () => { + const blocks = parseMarkdownToBlocks("> just a quote"); + expect(blocks[0].alertKind).toBeUndefined(); + }); + + test("marker-like text mid-quote is not treated as alert", () => { + const md = "> intro\n> [!NOTE]"; + const blocks = parseMarkdownToBlocks(md); + expect(blocks[0].alertKind).toBeUndefined(); + expect(blocks[0].content).toBe("intro\n[!NOTE]"); + }); +}); + +describe("parseMarkdownToBlocks — directive containers", () => { + test("captures body between :::kind and :::", () => { + const md = ":::note\nBody line.\n:::"; + const blocks = parseMarkdownToBlocks(md); + expect(blocks).toHaveLength(1); + expect(blocks[0].type).toBe("directive"); + expect(blocks[0].directiveKind).toBe("note"); + expect(blocks[0].content).toBe("Body line."); + }); + + test("supports arbitrary kinds (info, success, danger)", () => { + for (const kind of ["info", "success", "danger", "warning"]) { + const blocks = parseMarkdownToBlocks(`:::${kind}\nbody\n:::`); + expect(blocks[0].directiveKind).toBe(kind); + } + }); + + test("multi-paragraph body keeps blank-line separator", () => { + const md = ":::tip\npara 1\n\npara 2\n:::"; + const blocks = parseMarkdownToBlocks(md); + expect(blocks[0].content).toBe("para 1\n\npara 2"); + }); + + test("unterminated directive absorbs rest of document", () => { + // Not ideal, but prevents silent loss — user sees the whole body styled. + const md = ":::note\nbody\nmore body"; + const blocks = parseMarkdownToBlocks(md); + expect(blocks).toHaveLength(1); + expect(blocks[0].type).toBe("directive"); + expect(blocks[0].content).toBe("body\nmore body"); + }); + + test(":::kind with extra spaces still parses", () => { + const md = "::: note \nbody\n:::"; + const blocks = parseMarkdownToBlocks(md); + expect(blocks[0].type).toBe("directive"); + expect(blocks[0].directiveKind).toBe("note"); + }); +}); + +describe("parseMarkdownToBlocks — raw HTML blocks", () => { + test("
/ parsed as a single html block", () => { + const md = "
\nTitle\nBody text\n
"; + const blocks = parseMarkdownToBlocks(md); + expect(blocks).toHaveLength(1); + expect(blocks[0].type).toBe("html"); + expect(blocks[0].content).toBe(md); + }); + + test("blank line terminates the HTML block", () => { + const md = "
\nT\n
\n\nAfter paragraph"; + const blocks = parseMarkdownToBlocks(md); + expect(blocks).toHaveLength(2); + expect(blocks[0].type).toBe("html"); + expect(blocks[0].content).toBe("
\nT\n
"); + expect(blocks[1].type).toBe("paragraph"); + expect(blocks[1].content).toBe("After paragraph"); + }); + + test("EOF terminates the HTML block", () => { + const md = "
\nT\n
"; + const blocks = parseMarkdownToBlocks(md); + expect(blocks).toHaveLength(1); + expect(blocks[0].type).toBe("html"); + }); + + test("paragraph before HTML block is separated correctly", () => { + const md = "Some intro\n\n
\nT\n
"; + const blocks = parseMarkdownToBlocks(md); + expect(blocks).toHaveLength(2); + expect(blocks[0].type).toBe("paragraph"); + expect(blocks[0].content).toBe("Some intro"); + expect(blocks[1].type).toBe("html"); + }); + + test("non-allowlisted tag () falls through to paragraph — preserves prior behavior", () => { + const md = "not a block"; + const blocks = parseMarkdownToBlocks(md); + expect(blocks).toHaveLength(1); + expect(blocks[0].type).toBe("paragraph"); + expect(blocks[0].content).toBe("not a block"); + }); + + test("inline HTML in the middle of a paragraph is NOT a block", () => { + // Line does not start with ` { + const md = "
\nOuter\n
\nInner\nnested\n
\n
"; + const blocks = parseMarkdownToBlocks(md); + expect(blocks).toHaveLength(1); + expect(blocks[0].type).toBe("html"); + expect(blocks[0].content).toBe(md); + }); + + test("case-insensitive tag detection", () => { + const md = "
\nT\n
"; + const blocks = parseMarkdownToBlocks(md); + expect(blocks).toHaveLength(1); + expect(blocks[0].type).toBe("html"); + }); + + test("multiple HTML blocks separated by blank lines produce multiple blocks", () => { + const md = "
\nA\n
\n\n
\nB\n
"; + const blocks = parseMarkdownToBlocks(md); + expect(blocks).toHaveLength(2); + expect(blocks[0].type).toBe("html"); + expect(blocks[1].type).toBe("html"); + }); + + test("startLine points to first line of the HTML block", () => { + const md = "intro\n\n
\nT\n
"; + const blocks = parseMarkdownToBlocks(md); + expect(blocks[1].type).toBe("html"); + expect(blocks[1].startLine).toBe(3); + }); + + test("single-line inline HTML block (open + close on one line) is captured", () => { + const md = "
Tbody
"; + const blocks = parseMarkdownToBlocks(md); + expect(blocks).toHaveLength(1); + expect(blocks[0].type).toBe("html"); + expect(blocks[0].content).toBe(md); + }); + + test("blank line inside
does NOT terminate the block (GitHub-flavored)", () => { + const md = "
\nTitle\n\nBody across blanks.\n\n
"; + const blocks = parseMarkdownToBlocks(md); + expect(blocks).toHaveLength(1); + expect(blocks[0].type).toBe("html"); + expect(blocks[0].content).toBe(md); + }); + + test("nested same-tag open/close is balanced (not terminated by first close)", () => { + const md = "
\nOuter\n
\nInner\n
\nouter tail\n
"; + const blocks = parseMarkdownToBlocks(md); + expect(blocks).toHaveLength(1); + expect(blocks[0].type).toBe("html"); + expect(blocks[0].content).toBe(md); + }); + + test("trailing paragraph after closed
is a separate block", () => { + const md = "
\nT\n\nBody\n\n
\n\nAfter paragraph"; + const blocks = parseMarkdownToBlocks(md); + expect(blocks).toHaveLength(2); + expect(blocks[0].type).toBe("html"); + expect(blocks[1].type).toBe("paragraph"); + expect(blocks[1].content).toBe("After paragraph"); + }); +}); + describe("computeListIndices", () => { test("all unordered → all null", () => { const blocks = [li(0, false), li(0, false), li(0, false)]; diff --git a/packages/ui/utils/parser.ts b/packages/ui/utils/parser.ts index 841cc08b..a22aeb75 100644 --- a/packages/ui/utils/parser.ts +++ b/packages/ui/utils/parser.ts @@ -63,6 +63,25 @@ export function extractFrontmatter(markdown: string): { frontmatter: Frontmatter return { frontmatter, content: afterFrontmatter }; } +/** + * Tag names that trigger a raw HTML block per CommonMark §4.6, Type 6. + * A line starting with ` = new Set([ + 'details', 'summary', + 'div', 'section', 'article', 'aside', 'header', 'footer', + 'blockquote', 'pre', + 'table', 'thead', 'tbody', 'tr', 'td', 'th', + 'ul', 'ol', 'li', 'p', +]); + +const HTML_BLOCK_OPEN_RE = /^<\/?([a-zA-Z][a-zA-Z0-9]*)(?:\s|>|\/|$)/; + /** * A simplified markdown parser that splits content into linear blocks. * For a production app, we would use a robust AST walker (remark), @@ -201,12 +220,20 @@ export const parseMarkdownToBlocks = (markdown: string): Block[] => { !prevLineWasBlank && prevBlock?.type === 'blockquote' ) { - prevBlock.content += '\n' + stripped; + prevBlock.content = prevBlock.content + ? prevBlock.content + '\n' + stripped + : stripped; } else { + // GitHub alert marker: a blockquote whose first line is [!KIND]. + // We strip the marker from content and tag the block; rendering decides the style. + const alertMatch = stripped.match(/^\[!(NOTE|TIP|WARNING|CAUTION|IMPORTANT)\]\s*$/i); blocks.push({ id: `block-${currentId++}`, type: 'blockquote', - content: stripped, + content: alertMatch ? '' : stripped, + alertKind: alertMatch + ? (alertMatch[1].toLowerCase() as 'note' | 'tip' | 'warning' | 'caution' | 'important') + : undefined, order: currentId, startLine: currentLineNum }); @@ -269,6 +296,73 @@ export const parseMarkdownToBlocks = (markdown: string): Block[] => { continue; } + // Raw HTML blocks. A line starting with a known block-level HTML tag + // opens an HTML block. For opening tags we accumulate until the matching + // close tag is balanced (so `
…blank line…
` renders as + // one unit, matching GitHub's flavored behavior rather than strict + // CommonMark §4.6 Type 6 blank-line termination). For a line that starts + // with a close tag, we fall back to blank-line termination. Content is + // sanitized at render time, not here. + // Directive container: `:::kind` opens, `:::` closes. Inline kind is + // restricted to simple identifiers (letters, digits, hyphens). Body is + // accumulated verbatim and rendered with inline markdown. + const directiveOpen = trimmed.match(/^:::\s*([a-zA-Z][a-zA-Z0-9-]*)\s*$/); + if (directiveOpen) { + flush(); + const directiveStartLine = currentLineNum; + const kind = directiveOpen[1].toLowerCase(); + const bodyLines: string[] = []; + while (i + 1 < lines.length) { + i++; + if (lines[i].trim() === ':::') break; + bodyLines.push(lines[i]); + } + blocks.push({ + id: `block-${currentId++}`, + type: 'directive', + content: bodyLines.join('\n'), + directiveKind: kind, + order: currentId, + startLine: directiveStartLine, + }); + continue; + } + + const htmlTagMatch = trimmed.match(HTML_BLOCK_OPEN_RE); + if (htmlTagMatch && HTML_BLOCK_TAGS.has(htmlTagMatch[1].toLowerCase())) { + flush(); + const htmlStartLine = currentLineNum; + const tagName = htmlTagMatch[1].toLowerCase(); + const isCloseTag = trimmed.startsWith('|/|$)`, 'gi'); + const closeRe = new RegExp(``, 'gi'); + let depth = (line.match(openRe) || []).length - (line.match(closeRe) || []).length; + while (depth > 0 && i + 1 < lines.length) { + i++; + htmlLines.push(lines[i]); + depth += (lines[i].match(openRe) || []).length; + depth -= (lines[i].match(closeRe) || []).length; + } + } + + blocks.push({ + id: `block-${currentId++}`, + type: 'html', + content: htmlLines.join('\n'), + order: currentId, + startLine: htmlStartLine, + }); + continue; + } + // Empty lines separate paragraphs if (trimmed === '') { flush(); diff --git a/packages/ui/utils/sanitizeHtml.ts b/packages/ui/utils/sanitizeHtml.ts new file mode 100644 index 00000000..37d31daf --- /dev/null +++ b/packages/ui/utils/sanitizeHtml.ts @@ -0,0 +1,28 @@ +import DOMPurify from 'dompurify'; +import { marked } from 'marked'; + +const ALLOWED_TAGS = [ + 'sub', 'sup', 'b', 'i', 'em', 'strong', 'br', 'hr', 'p', 'span', + 'del', 'ins', 'mark', 'small', 'abbr', 'kbd', 'var', 'samp', + 'details', 'summary', 'blockquote', 'ul', 'ol', 'li', + 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'pre', 'code', + 'table', 'thead', 'tbody', 'tr', 'th', 'td', + 'a', 'img', 'div', 'section', 'article', 'aside', 'header', 'footer', +]; + +const ALLOWED_ATTR = [ + 'href', 'src', 'alt', 'title', 'rel', 'target', 'width', 'height', 'align', +]; + +/** + * Render and sanitize the content of a raw HTML block for injection via + * innerHTML. Content is first run through `marked` so that markdown nested + * between HTML tags (e.g. `**bun**` inside `
`) renders + * as real ``, matching GitHub's flavored behavior. Then DOMPurify + * strips anything outside the allowlist — no event handlers, no inline + * styles, no scripts. + */ +export function sanitizeBlockHtml(html: string): string { + const rendered = marked.parse(html, { async: false, gfm: true, breaks: false }) as string; + return DOMPurify.sanitize(rendered, { ALLOWED_TAGS, ALLOWED_ATTR }); +} diff --git a/packages/ui/utils/slugify.test.ts b/packages/ui/utils/slugify.test.ts new file mode 100644 index 00000000..97e858b1 --- /dev/null +++ b/packages/ui/utils/slugify.test.ts @@ -0,0 +1,44 @@ +import { describe, test, expect } from 'bun:test'; +import { slugifyHeading } from './slugify'; + +describe('slugifyHeading', () => { + test('lowercases plain text', () => { + expect(slugifyHeading('Installation')).toBe('installation'); + }); + + test('strips bold and italic markers', () => { + expect(slugifyHeading('**Install** _now_')).toBe('install-now'); + }); + + test('strips inline code backticks', () => { + expect(slugifyHeading('Install `bun`')).toBe('install-bun'); + }); + + test('strips link syntax, keeps label', () => { + expect(slugifyHeading('See [the docs](https://example.com)')).toBe('see-the-docs'); + }); + + test('strips wiki-link brackets, keeps text', () => { + expect(slugifyHeading('[[reference]] page')).toBe('reference-page'); + }); + + test('collapses runs of special characters', () => { + expect(slugifyHeading('CI / CD & deploy')).toBe('ci-cd-deploy'); + }); + + test('trims leading and trailing hyphens', () => { + expect(slugifyHeading(' Leading and trailing ')).toBe('leading-and-trailing'); + }); + + test('preserves unicode letters', () => { + expect(slugifyHeading('Café & résumé')).toBe('café-résumé'); + }); + + test('returns empty string for empty input', () => { + expect(slugifyHeading('')).toBe(''); + }); + + test('returns empty string for all-symbol input', () => { + expect(slugifyHeading('***')).toBe(''); + }); +}); diff --git a/packages/ui/utils/slugify.ts b/packages/ui/utils/slugify.ts new file mode 100644 index 00000000..b479810b --- /dev/null +++ b/packages/ui/utils/slugify.ts @@ -0,0 +1,14 @@ +/** + * Convert heading text into a URL-safe anchor id. + * Strips common inline markdown so `**Install** \`bun\`` → `install-bun`. + * Preserves unicode letters (e.g. "Café" → "café"). + */ +export function slugifyHeading(text: string): string { + return text + .toLowerCase() + .replace(/\[\[([^\]]+)\]\]/g, '$1') + .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') + .replace(/[*_`~]/g, '') + .replace(/[^\p{L}\p{N}]+/gu, '-') + .replace(/^-+|-+$/g, ''); +} diff --git a/tests/test-fixtures/11-html-blocks.md b/tests/test-fixtures/11-html-blocks.md new file mode 100644 index 00000000..6fb33e5d --- /dev/null +++ b/tests/test-fixtures/11-html-blocks.md @@ -0,0 +1,100 @@ +# Raw HTML Block Test Fixture + +This fixture exercises the new CommonMark Type 6 HTML block detection plus the +sanitized renderer. Headings and paragraphs around each block should render as +normal markdown — the HTML blocks should render as real HTML. + +## 1. GitHub-style collapsible section + +
+Prerequisites + +Install **bun** and clone the repo. The summary row is always visible; the +body renders only when expanded. Selecting text inside this expanded block +should trigger the annotation toolbar via web-highlighter. + +
+ +A regular paragraph after the collapsible. This should render as plain +markdown with **bold** and *italic* formatting intact. + +## 2. Nested collapsibles + +
+Outer section + +
+Inner section + +Nested details render as one HTML block (CommonMark Type 6 terminates on blank +line, not on matching close tag). + +
+ +
+ +## 3. Raw blockquote with inline formatting + +
+This blockquote was authored as raw HTML. It should render with the +.html-block blockquote styling from theme.css — a primary-tinted +left border and italic muted foreground. +
+ +## 4. Section / header / footer landmarks + +
+
Section header
+ +The section/article/aside/header/footer tags are in the allowlist and should +render as generic block containers. + +
Section footer
+
+ +## 5. Sanitizer smoke test + +
+Should strip scripts and event handlers + + + +

Click me — the onclick handler should be stripped by DOMPurify.

+ +This javascript: href should be neutralized. + +This safe https link should render normally. + +
+ +## 6. Non-allowlisted tag falls through + +This custom tag is NOT in the block-tag allowlist, so the parser should leave it as paragraph text and React will escape the angle brackets. + +## 7. Adjacent blocks separated by blank line + +
+First +One. +
+ +
+Second +Two. +
+ +## 8. Table authored as raw HTML + + + + + + + + + +
TagAllowed
detailsyes
scriptno — stripped
+ +## End + +Final paragraph — confirms that HTML blocks don't swallow trailing content. diff --git a/tests/test-fixtures/12-gfm-and-inline-extras.md b/tests/test-fixtures/12-gfm-and-inline-extras.md new file mode 100644 index 00000000..d31b1346 --- /dev/null +++ b/tests/test-fixtures/12-gfm-and-inline-extras.md @@ -0,0 +1,232 @@ +# v0.19 — Markdown polish & GitHub parity + +Bring the in-app reader to parity with what GitHub renders on `README.md`, and tighten the inline prose experience for authors who paste freely from PRs, chat, and notes. + +Target ship: end of sprint. Primary owner: @backnotprop. Reviewers: @alice, @bob. + +
+Files touched in this ship (click to expand) + +| File | Change | +|---|---| +| `packages/ui/types.ts` | Added `AlertKind`, `'directive'` union member, `alertKind` + `directiveKind` fields | +| `packages/ui/utils/parser.ts` | Alert detection on blockquotes, directive container parsing | +| `packages/ui/utils/slugify.ts` | **new** — `slugifyHeading()` | +| `packages/ui/utils/inlineTransforms.ts` | **new** — `transformPlainText()` | +| `packages/ui/components/InlineMarkdown.tsx` | Bare URL autolink, issue refs, mentions, plain-text transform integration | +| `packages/ui/components/BlockRenderer.tsx` | Heading anchor ids, alert case, directive case | +| `packages/ui/components/blocks/AlertBlock.tsx` | **new** — GitHub-parity alert rendering with Octicons | +| `packages/ui/components/blocks/Callout.tsx` | **new** — shared directive container | +| `packages/ui/theme.css` | Alert + directive color variants (light + dark) | + +
+ +--- + +## Why now + +Plannotator's reader is where plans *land* — a plan looks wrong here, it reads wrong everywhere. Authors routinely copy-paste from GitHub Issues (#412, #438), internal docs with smart punctuation, and chat threads full of `:emoji:`. Today those snippets render with straight quotes, literal `:wave:` shortcodes, and unlinked `#123` references. It's rough. + +> [!NOTE] +> This is scoped to the **reader** only. We're not touching write-path authoring, draft persistence, or the annotation store. Follow-ups for those live in #501 and #512. + +We also heard from @carol that the alert styling looked "AI-templated." That's fair — we shipped the first pass with uppercase titles and heavy background tint. This ship brings it in line with GitHub's actual Primer tokens. + +## What's included + +Eight additive rendering features, zero breaking changes: + +| Feature | Shortcut | Example | +|---|---|---| +| Heading anchors | automatic | `#why-now` scrolls here | +| Bare URL autolinks | automatic | https://github.com/backnotprop/plannotator | +| GitHub alerts | `> [!NOTE]` | see below | +| Directive containers | `:::note` | see below | +| Mentions | `@user` | @backnotprop | +| Issue refs | `#123` | #412 | +| Emoji shortcodes | `:name:` | :rocket: :sparkles: :tada: | +| Smart punctuation | automatic | "it's a feature" → “it’s a feature” | + +## Rollout plan + +:::info +We're landing this behind no flag — the features are additive and render safely against every historical plan. If rendering breaks for any saved plan, that's a parser regression, not a feature toggle issue. +::: + +Three PRs, in order: + +1. **Extract inline scanner** (#540) — moves `InlineMarkdown` out of `Viewer.tsx` so the next six features have somewhere clean to land. Pure refactor, no behavior change. Reviewed by @alice. +2. **Block-level features** (#541) — heading anchors, GitHub alerts, directive containers. Adds `slugifyHeading`, detects `[!NOTE]` markers on blockquotes, parses `:::kind` fences. Reviewed by @bob. +3. **Inline features** (#542) — bare URL autolinks, mentions, issue refs, emoji shortcodes, smart punctuation. Adds `inlineTransforms.ts` shared utility. Reviewed by @alice. + +Each PR is under 300 lines and ships with tests. Total new test count: +40. Merge order matters — #540 must land first or #541 and #542 will conflict on `Viewer.tsx`. + +--- + +## Feature walkthrough + +### Heading anchors + +Every heading now gets a deterministic id derived from its text, so URL fragments work: `plan.html#rollout-plan` jumps directly to that section. Inline markdown (bold, italic, code spans) is stripped before slugging, so `**Install** \`bun\`` becomes `install-bun`, not something gnarly. + +Unicode letters are preserved — `Café` becomes `café`. See #445 for the rationale (a contributor filed the issue after headings in their French-language plan produced empty ids). + +### Bare URL autolinks + +A URL in prose now becomes a link without needing `<>` or `[label](url)` wrapping. Paste https://plannotator.ai into a plan and it just works. Trailing sentence punctuation doesn't get swallowed into the URL: "Visit https://github.com/backnotprop/plannotator." keeps the period outside the link, same as GitHub. + +URLs inside backticks stay literal — `https://example.com/raw` shows as code. URLs inside explicit `[label](url)` markdown still route through the existing link handler. + +### GitHub alerts + +Five flavors, matching Primer exactly: + +> [!NOTE] +> Useful information that users should know, even when skimming content. Supports **bold**, `code`, and links like [the design doc](https://plannotator.ai/docs/alerts). + +> [!TIP] +> Helpful advice. Try running `bun run dev:hook` with a fixture at `tests/test-fixtures/12-gfm-and-inline-extras.md` to see this whole doc render live. + +> [!IMPORTANT] +> Key information readers must know. Talk to @backnotprop before cherry-picking this into a point release — there's context in #538 that's not in the PR description. + +> [!WARNING] +> Something that needs attention. If you add new alert kinds, update `AlertKind` in `packages/ui/types.ts` AND the icon mapping in `AlertBlock.tsx`. Forgetting the second causes a silent render fallback that only shows on the missing kind. + +> [!CAUTION] +> Negative consequences. Never ship a rename of `alertKind` without a shared-payload migration — annotations on older plans use text-search for restoration, so renaming the field silently orphans every alert-block annotation made before the change. See #489 for the migration playbook. + +### Directive containers + +Same visual family as alerts, but with arbitrary kinds. Useful for plan conventions that don't map cleanly to GitHub's five: + +:::tip +Directives shine for project-specific callouts — "deploy notes," "rollback steps," "monitoring checks." No need to shoehorn every concept into `note` / `tip` / `warning`. +::: + +:::warning +Multi-paragraph directives work too. + +Second paragraph here renders inline with the first. Bulletproof against blank lines inside the fence. +::: + +:::danger +Reserve `:::danger` for operations you genuinely cannot undo — production data migrations, destructive cloud resource commands, credential rotations that invalidate existing sessions. +::: + +:::success +Use `:::success` for completion markers in post-mortems and runbooks. Matches the green-check convention people already use in chat. +::: + +### Mentions and issue refs + +Write `@username` and `#123` in prose. When the plan lives inside a GitHub-linked repo, they render as real links to github.com. Otherwise they render as styled text — no broken links, no guessing. + +- Thanks @alice for pairing on the scanner extraction in #540. +- @bob, would you take the parser PR (#541)? It touches `packages/ui/utils/parser.ts` which you know best. +- @carol filed #445, #489, and the original alert-styling feedback in #538. +- Full discussion history: #412 → #438 → #489 → #538. + +Email addresses don't false-match: ramos@example.com is not a mention. Hex colors don't false-match: `#3b82f6` is still a color, not issue #3. Text inside code spans is always literal. + +### Emoji shortcodes + +:rocket: for releases, :bug: for bug fixes, :sparkles: for polish, :book: for docs, :tada: for celebration, :construction: for work-in-progress. The usual GitHub set. + +Inline in prose — "just finished the review :wave:" — renders as you'd expect. Inside backticks, shortcodes stay literal: `:wave:` shows the text form. Unknown shortcodes pass through untouched, so `:not_a_real_emoji:` doesn't silently eat your colons. + +### Smart punctuation + +Straight quotes curl based on context: "she said hello" becomes “she said hello”. Apostrophes in contractions — don't, won't, it's, they'd — all curl correctly. Dashes: two hyphens become an en-dash for ranges (pages 3--5), three become an em-dash (like this --- inline break). + +Ellipsis: three dots collapse to a proper character... which means author prose reads tighter on every screen size. + +Code stays literal. `"don't do this"` inside backticks keeps straight quotes, preserving any shell or regex that ships inside a code span. + +--- + +## Risks and mitigations + +> [!WARNING] +> The two real risk vectors we traced during design: +> +> **Annotation text-restoration drift.** Annotations store `originalText` captured from the rendered DOM. If smart punctuation turns `"hello"` into `“hello”`, old annotations made before this ship won't find themselves on reload — they'll silently disappear. Mitigation: text-search fallback normalizes both straight and curly forms during restoration. +> +> **Code-span corruption.** Naïve string replacements on `block.content` would curl quotes inside `` `code` `` spans — wrong. Mitigation: all inline transforms run inside `InlineMarkdown`'s plain-text path, which is only reached after code-span regex has already consumed code content. + +Neither risk is theoretical — we hit both during prototyping. See #541's PR description for the specific failing test cases and their fixes. + +## Non-goals + +- **Footnotes.** Not shipping. GitHub's `[^1]` syntax is a separate rabbit hole — tracked in #551. +- **Math rendering.** `$inline$` and `$$block$$` KaTeX support — tracked in #552. Would require pulling in `katex` (~280KB gzipped) which dominates the bundle. +- **Task-list metadata sync.** The existing checkbox rendering stays as-is. Two-way sync with upstream issue trackers is #553. +- **Full HTML passthrough.** We render `
` / `` via the existing raw-HTML block. Arbitrary inline HTML is still escaped by design — we don't want to re-open the XSS surface that sanitization solved in #489. + +## Testing + +```bash +# Unit tests +bun test packages/ui + +# Live render against this fixture +bun run build:hook && \ + bun run --cwd apps/hook server/index.ts annotate \ + tests/test-fixtures/12-gfm-and-inline-extras.md + +# Regression check: every plan in test-fixtures/ must still render identically +bun test:regression +``` + +All 149 existing tests continue to pass. +40 new tests across `slugify.test.ts`, `inlineTransforms.test.ts`, and the blockquote-alert / directive additions in `parser.test.ts`. + +
+Expected test output (click to expand) + +``` +bun test v1.3.11 +✓ parser.test.ts — 92 pass +✓ slugify.test.ts — 10 pass +✓ inlineTransforms.test.ts — 9 pass +✓ sanitizeHtml.test.ts — 7 pass +✓ planDiffEngine.test.ts — 14 pass +✓ sharing.test.ts — 11 pass +✓ diagramLanguages.test.ts — 6 pass + +149 pass +0 fail +383 expect() calls +Ran 149 tests across 7 files. [61.00ms] +``` + +**New test coverage added in this ship:** + +- `slugify.test.ts` — 10 cases: plain text, bold stripping, code-span stripping, link label extraction, wiki-link handling, special-character collapse, unicode preservation, empty-input fallback, all-symbol fallback, trailing-hyphen trim. +- `inlineTransforms.test.ts` — 9 cases: known shortcode replacement, unknown shortcode passthrough, multiple shortcodes, ellipsis, em-dash, en-dash, curly quotes, contraction apostrophe, single-quote phrases. +- `parser.test.ts` — 15 new cases covering GitHub alert detection (5 kinds, case-insensitive, marker-only, mid-quote rejection) and directive containers (basic, arbitrary kinds, multi-paragraph, unterminated absorption, spacing tolerance). + +
+## Open questions + +:::question +Should we ship the alert-styling update in a point release (0.18.1) rather than minor (0.19.0)? The visual delta is user-facing and arguably closer to "bug fix" than "feature" — the original styling genuinely didn't match GitHub. @alice, @bob — opinions? +::: + +One more: do we want `:::question` as a first-class directive kind with its own color, or is the generic blue enough? See #538 for the discussion. I lean generic — adding colors has a long tail of "add one more" requests. + +--- + +## Sign-off + +When this lands, the reader covers: + +- :white_check_mark: Every inline markdown pattern GitHub renders +- :white_check_mark: Every block-level pattern GitHub renders (alerts, details/summary, tables, code with syntax highlight) +- :white_check_mark: Directive containers for project-specific callouts +- :white_check_mark: Smart typography for pasted prose +- :construction: Footnotes — next sprint (#551) +- :construction: Math — deferred (#552) + +Approve to ship after @alice and @bob sign off. + +Ping @backnotprop in #538 with any objections — keeping review open through EOD Friday. From 3cbdd022b7d2d1ab4514b790a8cdb57c50847e49 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Tue, 21 Apr 2026 10:47:36 -0700 Subject: [PATCH 2/9] fix(ui): wire typecheck for packages/ui, address PR review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root-cause fix for the missing-import bug caught in review: the UI package had no tsconfig.json and no typecheck script, so missing references like `getImageSrc` in the extracted InlineMarkdown slipped past vite/esbuild (which only type-strip, they don't resolve imports). Infrastructure: - Added packages/ui/tsconfig.json with proper module resolution, JSX config, and bundler-style paths. - Added globals.d.ts to accept side-effect CSS imports. - Added @types/react, @types/react-dom, @types/bun, @types/dompurify as devDeps on packages/ui so React / Bun / DOMPurify types actually resolve. - Wired `tsc --noEmit -p packages/ui/tsconfig.json` into the top-level `bun run typecheck` script. With the typecheck running, 0 errors remain in this PR's scope. Four pre-existing errors on main (plan-diff SVG type narrowing, sharing.ts SharePayload shape) are unrelated and tracked separately. Review fixes: - InlineMarkdown: import getImageSrc from ImageThumbnail. Was calling the helper without importing it — markdown images with relative paths (`![alt](./foo.png)`) would throw ReferenceError at render. Regression caused by the InlineMarkdown extraction. - useAnnotationHighlighter: findTextInDOM now retries with the rendered form (transformPlainText) when the raw originalText doesn't match. Annotations made before smart-punctuation / emoji shortcodes shipped (straight quotes, `:wave:` text) still re-bind after reload. - sanitizeHtml: allow the `open` attribute so `
` preserves its default-expanded state instead of always rendering collapsed. - parser.test.ts: narrow a string->AlertKind assertion to satisfy strict typechecking. Deferred (tracked as known limitation in PR description): - HtmlBlock relative URL rewriting for nested / . New-feature gap, not a regression. For provenance purposes, this commit was AI assisted. --- bun.lock | 7 ++ package.json | 2 +- packages/ui/components/InlineMarkdown.tsx | 1 + packages/ui/globals.d.ts | 2 + packages/ui/hooks/useAnnotationHighlighter.ts | 110 +++++++++++------- packages/ui/package.json | 10 ++ packages/ui/tsconfig.json | 33 ++++++ packages/ui/utils/parser.test.ts | 2 +- packages/ui/utils/sanitizeHtml.ts | 1 + 9 files changed, 121 insertions(+), 47 deletions(-) create mode 100644 packages/ui/globals.d.ts create mode 100644 packages/ui/tsconfig.json diff --git a/bun.lock b/bun.lock index e190b90f..b7ea409a 100644 --- a/bun.lock +++ b/bun.lock @@ -218,6 +218,13 @@ "react-dom": "^19.2.3", "unique-username-generator": "^1.5.1", }, + "devDependencies": { + "@types/bun": "^1.2.0", + "@types/dompurify": "^3.0.5", + "@types/react": "^19.2.0", + "@types/react-dom": "^19.2.0", + "typescript": "~5.8.2", + }, }, }, "packages": { diff --git a/package.json b/package.json index 577727c8..31205dd9 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "build:vscode": "bun run --cwd apps/vscode-extension build", "package:vscode": "bun run --cwd apps/vscode-extension package", "test": "bun test", - "typecheck": "bash apps/pi-extension/vendor.sh && tsc --noEmit -p packages/shared/tsconfig.json && tsc --noEmit -p packages/ai/tsconfig.json && tsc --noEmit -p packages/server/tsconfig.json && tsc --noEmit -p apps/pi-extension/tsconfig.json" + "typecheck": "bash apps/pi-extension/vendor.sh && tsc --noEmit -p packages/shared/tsconfig.json && tsc --noEmit -p packages/ai/tsconfig.json && tsc --noEmit -p packages/server/tsconfig.json && tsc --noEmit -p packages/ui/tsconfig.json && tsc --noEmit -p apps/pi-extension/tsconfig.json" }, "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.2.92", diff --git a/packages/ui/components/InlineMarkdown.tsx b/packages/ui/components/InlineMarkdown.tsx index a68a177d..255451bd 100644 --- a/packages/ui/components/InlineMarkdown.tsx +++ b/packages/ui/components/InlineMarkdown.tsx @@ -1,5 +1,6 @@ import React from "react"; import { transformPlainText } from "../utils/inlineTransforms"; +import { getImageSrc } from "./ImageThumbnail"; const DANGEROUS_PROTOCOL = /^\s*(javascript|data|vbscript|file)\s*:/i; function sanitizeLinkUrl(url: string): string | null { diff --git a/packages/ui/globals.d.ts b/packages/ui/globals.d.ts new file mode 100644 index 00000000..fedbc23d --- /dev/null +++ b/packages/ui/globals.d.ts @@ -0,0 +1,2 @@ +// Allow side-effect CSS imports (highlight.js themes, overlayscrollbars, etc.) +declare module '*.css'; diff --git a/packages/ui/hooks/useAnnotationHighlighter.ts b/packages/ui/hooks/useAnnotationHighlighter.ts index 931648e7..63ab2dca 100644 --- a/packages/ui/hooks/useAnnotationHighlighter.ts +++ b/packages/ui/hooks/useAnnotationHighlighter.ts @@ -11,6 +11,7 @@ import type { Annotation, EditorMode, ImageAttachment } from '../types'; import { AnnotationType } from '../types'; import type { QuickLabel } from '../utils/quickLabels'; import { getIdentity } from '../utils/identity'; +import { transformPlainText } from '../utils/inlineTransforms'; // --- Exported state types --- @@ -104,63 +105,82 @@ export function useAnnotationHighlighter({ const findTextInDOM = useCallback((searchText: string): Range | null => { if (!containerRef.current) return null; - const walker = document.createTreeWalker( - containerRef.current, - NodeFilter.SHOW_TEXT, - null - ); + // Search for an exact substring match inside the container's text tree. + // Falls back to a multi-text-node walk when the match spans siblings. + const searchOnce = (needle: string): Range | null => { + if (!needle || !containerRef.current) return null; - let node: Text | null; - while ((node = walker.nextNode() as Text | null)) { - const text = node.textContent || ''; - const index = text.indexOf(searchText); - if (index !== -1) { - const range = document.createRange(); - range.setStart(node, index); - range.setEnd(node, index + searchText.length); - return range; + const walker = document.createTreeWalker( + containerRef.current, + NodeFilter.SHOW_TEXT, + null + ); + + let node: Text | null; + while ((node = walker.nextNode() as Text | null)) { + const text = node.textContent || ''; + const index = text.indexOf(needle); + if (index !== -1) { + const range = document.createRange(); + range.setStart(node, index); + range.setEnd(node, index + needle.length); + return range; + } } - } - // Try across multiple text nodes for multi-line content - const fullText = containerRef.current.textContent || ''; - const searchIndex = fullText.indexOf(searchText); - if (searchIndex === -1) return null; + const fullText = containerRef.current.textContent || ''; + const searchIndex = fullText.indexOf(needle); + if (searchIndex === -1) return null; - const walker2 = document.createTreeWalker( - containerRef.current, - NodeFilter.SHOW_TEXT, - null - ); + const walker2 = document.createTreeWalker( + containerRef.current, + NodeFilter.SHOW_TEXT, + null + ); - let charCount = 0; - let startNode: Text | null = null; - let startOffset = 0; - let endNode: Text | null = null; - let endOffset = 0; + let charCount = 0; + let startNode: Text | null = null; + let startOffset = 0; + let endNode: Text | null = null; + let endOffset = 0; - while ((node = walker2.nextNode() as Text | null)) { - const nodeLength = node.textContent?.length || 0; + while ((node = walker2.nextNode() as Text | null)) { + const nodeLength = node.textContent?.length || 0; - if (!startNode && charCount + nodeLength > searchIndex) { - startNode = node; - startOffset = searchIndex - charCount; + if (!startNode && charCount + nodeLength > searchIndex) { + startNode = node; + startOffset = searchIndex - charCount; + } + + if (startNode && charCount + nodeLength >= searchIndex + needle.length) { + endNode = node; + endOffset = searchIndex + needle.length - charCount; + break; + } + + charCount += nodeLength; } - if (startNode && charCount + nodeLength >= searchIndex + searchText.length) { - endNode = node; - endOffset = searchIndex + searchText.length - charCount; - break; + if (startNode && endNode) { + const range = document.createRange(); + range.setStart(startNode, startOffset); + range.setEnd(endNode, endOffset); + return range; } - charCount += nodeLength; - } + return null; + }; + + // First try the literal text. If that misses, re-try with the same + // transform the renderer applies to plain text (emoji shortcodes + + // smart punctuation) so annotations made before those transforms + // shipped can still re-bind to their target after reload. + const direct = searchOnce(searchText); + if (direct) return direct; - if (startNode && endNode) { - const range = document.createRange(); - range.setStart(startNode, startOffset); - range.setEnd(endNode, endOffset); - return range; + const transformed = transformPlainText(searchText); + if (transformed !== searchText) { + return searchOnce(transformed); } return null; diff --git a/packages/ui/package.json b/packages/ui/package.json index 082738f5..7c7b6762 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -28,5 +28,15 @@ "react": "^19.2.3", "react-dom": "^19.2.3", "unique-username-generator": "^1.5.1" + }, + "devDependencies": { + "@types/bun": "^1.2.0", + "@types/dompurify": "^3.0.5", + "@types/react": "^19.2.0", + "@types/react-dom": "^19.2.0", + "typescript": "~5.8.2" + }, + "scripts": { + "typecheck": "tsc --noEmit -p tsconfig.json" } } diff --git a/packages/ui/tsconfig.json b/packages/ui/tsconfig.json new file mode 100644 index 00000000..683f100a --- /dev/null +++ b/packages/ui/tsconfig.json @@ -0,0 +1,33 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "jsx": "react-jsx", + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "resolveJsonModule": true, + "skipLibCheck": true, + "noEmit": true, + "strict": false, + "noImplicitAny": false, + "strictNullChecks": false, + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "types": ["bun"], + "paths": { + "@plannotator/shared": ["../shared/index.ts"], + "@plannotator/shared/*": ["../shared/*"] + } + }, + "include": [ + "**/*.ts", + "**/*.tsx" + ], + "exclude": [ + "node_modules", + "dist" + ] +} diff --git a/packages/ui/utils/parser.test.ts b/packages/ui/utils/parser.test.ts index cc5f79db..01a8548f 100644 --- a/packages/ui/utils/parser.test.ts +++ b/packages/ui/utils/parser.test.ts @@ -527,7 +527,7 @@ describe("parseMarkdownToBlocks — GitHub alerts", () => { test("detects each GitHub alert kind, case-insensitive", () => { for (const kind of ["NOTE", "TIP", "WARNING", "CAUTION", "IMPORTANT"]) { const blocks = parseMarkdownToBlocks(`> [!${kind}]\n> body`); - expect(blocks[0].alertKind).toBe(kind.toLowerCase()); + expect(blocks[0].alertKind).toBe(kind.toLowerCase() as 'note' | 'tip' | 'warning' | 'caution' | 'important'); } const lower = parseMarkdownToBlocks("> [!note]\n> body"); expect(lower[0].alertKind).toBe("note"); diff --git a/packages/ui/utils/sanitizeHtml.ts b/packages/ui/utils/sanitizeHtml.ts index 37d31daf..a9debd89 100644 --- a/packages/ui/utils/sanitizeHtml.ts +++ b/packages/ui/utils/sanitizeHtml.ts @@ -12,6 +12,7 @@ const ALLOWED_TAGS = [ const ALLOWED_ATTR = [ 'href', 'src', 'alt', 'title', 'rel', 'target', 'width', 'height', 'align', + 'open', // preserve
default-expanded state ]; /** From 28828ecf9f390df5c3074a7dd723c487eb7a79e3 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Tue, 21 Apr 2026 10:51:41 -0700 Subject: [PATCH 3/9] fix(ui): HtmlBlock rewrites relative / refs to match markdown paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Raw HTML blocks inject sanitized HTML verbatim, so nested and resolved against the plannotator server URL instead of the plan's directory — images 404'd, .md links navigated away instead of opening in the linked-doc overlay. This is the path README.md content hits (hero , YouTube thumbnails,
sections with anchors). Fix: after setting innerHTML, walk and elements and apply the same rewriting markdown content uses: - relative src → getImageSrc(src, imageBaseDir), routing through /api/image?path=... with the plan's base directory. - relative href matching .md / .mdx / .html → click handler that calls onOpenLinkedDoc, same pattern as [label](./foo.md) markdown links. - http(s):, data:, blob:, mailto:, tel:, and #anchor hrefs pass through untouched. BlockRenderer now threads imageBaseDir + onOpenLinkedDoc into HtmlBlock. React.memo equality extended to compare those props too, so legitimate changes still re-run the rewrite pass without forcing re-renders on every parent update. Verified against the repo's own README.md — hero image, YouTube thumbnails, and
sections all render correctly in annotate mode. For provenance purposes, this commit was AI assisted. --- packages/ui/components/BlockRenderer.tsx | 2 +- packages/ui/components/blocks/HtmlBlock.tsx | 58 +++++++++++++++++++-- 2 files changed, 55 insertions(+), 5 deletions(-) diff --git a/packages/ui/components/BlockRenderer.tsx b/packages/ui/components/BlockRenderer.tsx index cda559e5..c7aa4a6d 100644 --- a/packages/ui/components/BlockRenderer.tsx +++ b/packages/ui/components/BlockRenderer.tsx @@ -159,7 +159,7 @@ export const BlockRenderer: React.FC<{ return
; case 'html': - return ; + return ; case 'directive': { const kind = block.directiveKind || 'note'; diff --git a/packages/ui/components/blocks/HtmlBlock.tsx b/packages/ui/components/blocks/HtmlBlock.tsx index 5702a1c8..1a8e3775 100644 --- a/packages/ui/components/blocks/HtmlBlock.tsx +++ b/packages/ui/components/blocks/HtmlBlock.tsx @@ -1,6 +1,51 @@ import React, { useRef, useEffect } from "react"; import { Block } from "../../types"; import { sanitizeBlockHtml } from "../../utils/sanitizeHtml"; +import { getImageSrc } from "../ImageThumbnail"; + +interface HtmlBlockProps { + block: Block; + imageBaseDir?: string; + onOpenLinkedDoc?: (path: string) => void; +} + +// Walks the sanitized DOM and rewrites relative /
so they +// behave the same as their markdown counterparts: +// - Relative image paths route through /api/image?path=... so they load from +// the plan's directory, not the plannotator server root. +// - Relative .md / .mdx / .html links open in the linked-doc overlay when +// onOpenLinkedDoc is provided (same as [[wiki-links]] and [label](./x.md)). +// Absolute http(s) URLs and mailto: are left untouched. +function rewriteRelativeRefs( + root: HTMLElement, + imageBaseDir?: string, + onOpenLinkedDoc?: (path: string) => void, +): (() => void) { + const cleanups: (() => void)[] = []; + + root.querySelectorAll('img').forEach((img) => { + const src = img.getAttribute('src'); + if (!src) return; + if (/^(https?:|data:|blob:)/i.test(src)) return; + img.setAttribute('src', getImageSrc(src, imageBaseDir)); + }); + + root.querySelectorAll('a').forEach((a) => { + const href = a.getAttribute('href'); + if (!href) return; + if (/^(https?:|mailto:|tel:|#)/i.test(href)) return; + if (onOpenLinkedDoc && /\.(mdx?|html?)(#.*)?$/i.test(href)) { + const handler = (e: Event) => { + e.preventDefault(); + onOpenLinkedDoc(href.replace(/#.*$/, '')); + }; + a.addEventListener('click', handler); + cleanups.push(() => a.removeEventListener('click', handler)); + } + }); + + return () => cleanups.forEach((fn) => fn()); +} // The inner HTML is set imperatively (not via dangerouslySetInnerHTML) so that // React's reconciliation never replaces the rendered subtree on re-render. @@ -8,17 +53,20 @@ import { sanitizeBlockHtml } from "../../utils/sanitizeHtml"; // re-set on every parent re-render would collapse any open
the // user just opened. Paired with React.memo below so the component itself // stops re-rendering unless the block content actually changes. -const HtmlBlockImpl: React.FC<{ block: Block }> = ({ block }) => { +const HtmlBlockImpl: React.FC = ({ block, imageBaseDir, onOpenLinkedDoc }) => { const ref = useRef(null); const sanitized = React.useMemo( () => sanitizeBlockHtml(block.content), [block.content], ); useEffect(() => { - if (ref.current && ref.current.innerHTML !== sanitized) { + if (!ref.current) return; + if (ref.current.innerHTML !== sanitized) { ref.current.innerHTML = sanitized; } - }, [sanitized]); + const cleanup = rewriteRelativeRefs(ref.current, imageBaseDir, onOpenLinkedDoc); + return cleanup; + }, [sanitized, imageBaseDir, onOpenLinkedDoc]); return (
prev.block.id === next.block.id && - prev.block.content === next.block.content, + prev.block.content === next.block.content && + prev.imageBaseDir === next.imageBaseDir && + prev.onOpenLinkedDoc === next.onOpenLinkedDoc, ); From 66b48fb52c548109a5da3842accc42f4568c08b6 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Tue, 21 Apr 2026 12:52:50 -0700 Subject: [PATCH 4/9] =?UTF-8?q?feat(ui):=20table=20conveniences=20?= =?UTF-8?q?=E2=80=94=20hover=20toolbar,=20popout=20dialog=20with=20sort/fi?= =?UTF-8?q?lter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extracts table rendering into blocks/TableBlock and adds two companion surfaces: a hover toolbar for quick copy, and a full-screen popout dialog with TanStack-powered sort/filter/copy for power use. No pagination — plan tables don't get that big. Hover toolbar (blocks/TableToolbar.tsx): - Floats above the table on mouse enter via React portal, positioned with getBoundingClientRect + scroll/resize listeners, entry/exit animations. Same positional pattern as AnnotationToolbar's top-right mode. - Debounced hover state in Viewer (100ms leave → 150ms exit animation), mirroring hoveredCodeBlock's state machine. - Three actions: Copy markdown (icon), CSV (short uppercase button, RFC 4180 escaping), Expand (opens popout). Popout dialog (blocks/TablePopout.tsx): - Radix Dialog, fullscreen-ish card with ~2rem backdrop visible for click-to-close. max-w-[min(calc(100vw-4rem),1500px)]. - Portaled into Viewer's containerRef so the annotation hook can walk into the popout's text nodes — selection-based annotations, text-search restoration, and shared blockId all work across the collapsed and popped-out views. - TanStack Table for the grid: click column headers to sort (asc → desc → clear), global filter input, no pagination. Row count indicator shows "15 of 27" when filter reduces the set. - Copy / CSV buttons in the header row: filter- and sort-aware. When visible rows < total, tooltips read "Copy 15 rows as markdown" / "Copy 15 rows as CSV". When no filter, copies whole table (normalized whitespace). Read is one-shot on click — no derived state to sync. - Floating X close button (absolute top-right), no header bar. Chrome stacking while popout is open (CSS-only, via :has()): - body:has([data-popout="true"]) drops four element types behind the dialog: annotation sidebar, sticky header lane, app nav header, overlay scrollbars. :has() observes the dialog's presence directly — when the dialog unmounts, the selector stops matching and everything returns to natural stacking. No JS state, no useEffect cleanup. Shared helpers in TableBlock.tsx (exported): - parseTableContent — pipe-delimited markdown → { headers, rows } - buildCsvFromRows / buildMarkdownTable — inverse, from parsed data - buildCsv — thin wrapper for the hover toolbar's raw-block path Dependencies added: - @radix-ui/react-dialog ^1.1.15 (~6 KB gzipped) - @tanstack/react-table ^8.21.3 (~14 KB gzipped) Fixture: - tests/test-fixtures/12-gfm-and-inline-extras.md — added a 27×11 "Detailed feature backlog" table to exercise wide + deep tables, horizontal scroll in the popout, and the sort/filter flows. For provenance purposes, this commit was AI assisted. --- bun.lock | 28 ++ packages/editor/App.tsx | 2 +- packages/ui/components/AnnotationPanel.tsx | 1 + packages/ui/components/BlockRenderer.tsx | 68 +---- packages/ui/components/StickyHeaderLane.tsx | 1 + packages/ui/components/Viewer.tsx | 84 ++++++ packages/ui/components/blocks/TableBlock.tsx | 133 +++++++++ packages/ui/components/blocks/TablePopout.tsx | 278 ++++++++++++++++++ .../ui/components/blocks/TableToolbar.tsx | 153 ++++++++++ packages/ui/package.json | 2 + packages/ui/theme.css | 11 + .../test-fixtures/12-gfm-and-inline-extras.md | 34 +++ 12 files changed, 735 insertions(+), 60 deletions(-) create mode 100644 packages/ui/components/blocks/TableBlock.tsx create mode 100644 packages/ui/components/blocks/TablePopout.tsx create mode 100644 packages/ui/components/blocks/TableToolbar.tsx diff --git a/bun.lock b/bun.lock index b7ea409a..559b2151 100644 --- a/bun.lock +++ b/bun.lock @@ -205,7 +205,9 @@ "dependencies": { "@plannotator/shared": "workspace:*", "@plannotator/web-highlighter": "^0.8.1", + "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-tooltip": "^1.2.8", + "@tanstack/react-table": "^8.21.3", "@viz-js/viz": "^3.25.0", "diff": "^8.0.3", "highlight.js": "^11.11.1", @@ -714,8 +716,14 @@ "@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + "@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw=="], + "@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg=="], + "@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw=="], + + "@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw=="], + "@radix-ui/react-id": ["@radix-ui/react-id@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="], "@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.8", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw=="], @@ -962,6 +970,10 @@ "@tailwindcss/vite": ["@tailwindcss/vite@4.1.18", "", { "dependencies": { "@tailwindcss/node": "4.1.18", "@tailwindcss/oxide": "4.1.18", "tailwindcss": "4.1.18" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA=="], + "@tanstack/react-table": ["@tanstack/react-table@8.21.3", "", { "dependencies": { "@tanstack/table-core": "8.21.3" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww=="], + + "@tanstack/table-core": ["@tanstack/table-core@8.21.3", "", {}, "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg=="], + "@textlint/ast-node-types": ["@textlint/ast-node-types@15.5.2", "", {}, "sha512-fCaOxoup5LIyBEo7R1oYWE7V4bSX0KQeHh66twon9e9usaLE3ijgF8QjYsR6joCssdeCHVd0wHm7ppsEyTr6vg=="], "@textlint/linter-formatter": ["@textlint/linter-formatter@15.5.2", "", { "dependencies": { "@azu/format-text": "^1.0.2", "@azu/style-format": "^1.0.1", "@textlint/module-interop": "15.5.2", "@textlint/resolver": "15.5.2", "@textlint/types": "15.5.2", "chalk": "^4.1.2", "debug": "^4.4.3", "js-yaml": "^4.1.1", "lodash": "^4.17.23", "pluralize": "^2.0.0", "string-width": "^4.2.3", "strip-ansi": "^6.0.1", "table": "^6.9.0", "text-table": "^0.2.0" } }, "sha512-jAw7jWM8+wU9cG6Uu31jGyD1B+PAVePCvnPKC/oov+2iBPKk3ao30zc/Itmi7FvXo4oPaL9PmzPPQhyniPVgVg=="], @@ -1154,6 +1166,8 @@ "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + "aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="], + "aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="], "array-iterate": ["array-iterate@2.0.1", "", {}, "sha512-I1jXZMjAgCMmxT4qxXfPXa6SthSoE8h6gkSI9BGGNv8mP8G/v0blc+qFnZu6K42vTOiuME596QaLO0TP3Lk0xg=="], @@ -1428,6 +1442,8 @@ "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + "detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="], + "deterministic-object-hash": ["deterministic-object-hash@2.0.2", "", { "dependencies": { "base-64": "^1.0.0" } }, "sha512-KxektNH63SrbfUyDiwXqRb1rLwKt33AmMv+5Nhsw1kqZ13SJBRTgZHtGbE+hH3a1mVW1cz+4pqSWVPAtLVXTzQ=="], "devalue": ["devalue@5.6.2", "", {}, "sha512-nPRkjWzzDQlsejL1WVifk5rvcFi/y1onBRxjaFMjZeR9mFpqu2gmAZ9xUB9/IEanEP/vBtGeGganC/GO1fmufg=="], @@ -1604,6 +1620,8 @@ "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], + "get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="], + "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], "get-source": ["get-source@2.0.12", "", { "dependencies": { "data-uri-to-buffer": "^2.0.0", "source-map": "^0.6.1" } }, "sha512-X5+4+iD+HoSeEED+uwrQ07BOQr0kEDFMVqqpBuI+RaZBpBpHCuXxo70bjar6f0b0u/DQJsJ7ssurpP0V60Az+w=="], @@ -2182,6 +2200,12 @@ "react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="], + "react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="], + + "react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="], + + "react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="], + "read": ["read@1.0.7", "", { "dependencies": { "mute-stream": "~0.0.4" } }, "sha512-rSOKNYUmaxy0om1BNjMN4ezNT6VKK+2xF4GBhc81mkH7L60i6dp8qPYrkndNLT3QPphoII3maL9PVC9XmhHwVQ=="], "read-pkg": ["read-pkg@9.0.1", "", { "dependencies": { "@types/normalize-package-data": "^2.4.3", "normalize-package-data": "^6.0.0", "parse-json": "^8.0.0", "type-fest": "^4.6.0", "unicorn-magic": "^0.1.0" } }, "sha512-9viLL4/n1BJUCT1NXVTdS1jtm80yDEgR5T4yCelII49Mbj0v1rZdKqj7zCiYdbB0CuCgdrvHcNogAKTFPBocFA=="], @@ -2496,6 +2520,10 @@ "url-join": ["url-join@4.0.1", "", {}, "sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA=="], + "use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="], + + "use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="], + "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], "uuid": ["uuid@11.1.0", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A=="], diff --git a/packages/editor/App.tsx b/packages/editor/App.tsx index f20c3dba..46b2ac5d 100644 --- a/packages/editor/App.tsx +++ b/packages/editor/App.tsx @@ -1365,7 +1365,7 @@ const App: React.FC = () => {
{/* Minimal Header */} -
+
= ({ const panel = (