Skip to content

feat(markdown): add MarkdownRenderer with code/Mermaid/CSV/Survey/HtmlPreview blocks#233

Open
rajkumargaramie wants to merge 2 commits into
mainfrom
markdown-components
Open

feat(markdown): add MarkdownRenderer with code/Mermaid/CSV/Survey/HtmlPreview blocks#233
rajkumargaramie wants to merge 2 commits into
mainfrom
markdown-components

Conversation

@rajkumargaramie
Copy link
Copy Markdown
Contributor

Adds a self-contained Markdown rendering component so consumer apps (e.g. ozwell-workspace) don't have to re-implement the marked + DOMPurify + highlight.js pipeline themselves.

What's included

  • MarkdownRenderer — full pipeline (marked GFM + DOMPurify sanitisation + hljs highlighting + React portals for special blocks)
  • FenceBlock — base wrapper (copy button, raw/rendered toggle, error state)
  • MermaidBlock — sandboxed Mermaid diagram rendering
  • CsvBlock — sortable / exportable CSV table
  • SurveyBlock — JSON/YAML survey preview
  • HtmlPreviewBlock — sandboxed iframe HTML preview with expand
  • useMarkdown — hook (render / renderAsync / clearCache)
  • atom-one-light / atom-one-dark themes bundled and scoped via [data-theme]
  • Copy-to-clipboard button on every fenced code block

Exports

  • @mieweb/ui — all of the above (named exports)
  • @mieweb/ui/components/Markdown — tree-shakeable subpath
  • @mieweb/ui/markdown.css — bundled hljs themes + fence-block styles

Dependencies

  • Hard deps (added): marked, dompurify, highlight.js — small, always-needed for the markdown pipeline
  • Optional peer deps: mermaid, papaparse, js-yaml — only loaded when the matching block type is used. Apps that never render Mermaid/CSV/Survey blocks don't pay the bundle cost.

Why

Originally landed in ozwell-workspace; centralising here so other products consuming @mieweb/ui's AIChat (via renderTextContent) can get full Markdown + code-block rendering for free.

Usage

import { MarkdownRenderer } from '@mieweb/ui';
import '@mieweb/ui/markdown.css';

<AIChat
  renderTextContent={(text, { messageId, streaming }) => (
    <MarkdownRenderer text={text} streaming={streaming} cacheKey={messageId} />
  )}
/>

…lPreview blocks

Adds a self-contained Markdown rendering component with:
- marked + DOMPurify pipeline
- highlight.js syntax highlighting (eager: js/ts/py/json/xml/css/bash/sql/yaml/md;
  lazy: java/cpp/c/csharp/php/ruby/go/rust/perl/r/makefile/dockerfile)
- Copy-to-clipboard button on fenced code blocks
- React-portal blocks for mermaid diagrams, CSV tables, survey previews, and
  sandboxed HTML previews
- atom-one-light / atom-one-dark theme bundled and scoped via [data-theme]

Exports:
- '@mieweb/ui' — { MarkdownRenderer, FenceBlock, MermaidBlock, CsvBlock,
  SurveyBlock, HtmlPreviewBlock, useMarkdown }
- '@mieweb/ui/components/Markdown' — same, as tree-shakeable subpath
- '@mieweb/ui/markdown.css' — bundled hljs themes + fence block styles

mermaid, papaparse, and js-yaml are optional peer dependencies so consumers
that don't use those block types don't pay the bundle cost.
Copilot AI review requested due to automatic review settings May 14, 2026 23:37
@rajkumargaramie rajkumargaramie review requested due to automatic review settings May 14, 2026 23:39
@rajkumargaramie rajkumargaramie self-assigned this May 14, 2026
@cloudflare-workers-and-pages
Copy link
Copy Markdown

cloudflare-workers-and-pages Bot commented May 14, 2026

Deploying ui with  Cloudflare Pages  Cloudflare Pages

Latest commit: ffb15f2
Status: ✅  Deploy successful!
Preview URL: https://2a107ea6.ui-6d0.pages.dev
Branch Preview URL: https://markdown-components.ui-6d0.pages.dev

View logs

Copilot AI review requested due to automatic review settings May 15, 2026 00:18
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a new Markdown rendering subsystem to @mieweb/ui that converts markdown to sanitized HTML, highlights code blocks, and mounts React-powered “special fence blocks” (Mermaid/CSV/Survey/HTML preview) via portals—plus a separate exported stylesheet (@mieweb/ui/markdown.css) for highlight.js + fence styling.

Changes:

  • Introduces MarkdownRenderer + useMarkdown pipeline using marked + DOMPurify + highlight.js, including placeholder emission for special fenced blocks.
  • Adds block components (FenceBlock, MermaidBlock, CsvBlock, SurveyBlock, HtmlPreviewBlock) and bundled highlight/fence CSS.
  • Updates build/package exports and dependency config to ship a tree-shakeable Markdown subpath and a dedicated markdown.css export.

Reviewed changes

Copilot reviewed 12 out of 13 changed files in this pull request and generated 13 comments.

Show a summary per file
File Description
tsup.config.ts Adds a tree-shakeable Markdown entry point and marks optional block deps as externals.
src/index.ts Re-exports Markdown components from the package root.
src/components/Markdown/useMarkdown.ts Implements marked→sanitized HTML rendering, highlight.js integration, caching, placeholder emission, and delegated copy handler.
src/components/Markdown/MarkdownRenderer.tsx Renders sanitized HTML and mounts block components into placeholders via portals.
src/components/Markdown/FenceBlock.tsx Provides a reusable UI wrapper for fenced blocks (copy + raw/rendered toggle + error state).
src/components/Markdown/MermaidBlock.tsx Adds Mermaid diagram rendering (dynamic import) into FenceBlock.
src/components/Markdown/CsvBlock.tsx Adds CSV parsing + sortable/exportable table block.
src/components/Markdown/SurveyBlock.tsx Adds JSON/YAML survey preview block.
src/components/Markdown/HtmlPreviewBlock.tsx Adds sandboxed iframe HTML preview with expand-to-modal behavior.
src/components/Markdown/styles.css Bundles/scopes highlight.js themes and fence-block styles; exported as @mieweb/ui/markdown.css.
src/components/Markdown/index.ts Exposes the Markdown module’s public exports.
package.json Adds markdown.css export + build copy step; adds hard deps (marked/dompurify/highlight.js) and optional peer deps metadata.
pnpm-lock.yaml Locks new dependencies introduced by the Markdown feature.
Files not reviewed (1)
  • pnpm-lock.yaml: Language not supported
Comments suppressed due to low confidence (1)

src/components/Markdown/MermaidBlock.tsx:76

  • dangerouslySetInnerHTML is used to inject Mermaid’s generated SVG. Even with a safer securityLevel, it’s worth sanitizing/validating the SVG string (or rendering via a DOM API) before injection to reduce XSS risk from malformed output or upstream changes.
        <div
          ref={containerRef}
          className="flex justify-center p-4"
          dangerouslySetInnerHTML={{ __html: svg }}
        />

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +114 to +129
function sanitise(html: string): string {
return DOMPurify.sanitize(html, {
ADD_TAGS: ['iframe'],
ADD_ATTR: [
'target',
'rel',
'allow',
'allowfullscreen',
'sandbox',
'srcdoc',
'data-block-type',
'data-block-id',
'data-code',
'data-lang',
],
WHOLE_DOCUMENT: false,
Comment on lines +136 to +142
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
if (node instanceof HTMLAnchorElement) {
node.setAttribute('target', '_blank');
node.setAttribute('rel', 'noopener noreferrer');
}
});

Comment thread src/components/Markdown/useMarkdown.ts Outdated
Comment on lines +158 to +182
if (typeof document !== 'undefined') {
document.addEventListener('click', (event) => {
const btn =
event.target instanceof Element
? event.target.closest<HTMLButtonElement>('.fence-copy-btn')
: null;
if (!btn) return;
const encoded = btn.closest('.fence-block')?.getAttribute('data-code');
if (!encoded) return;
const code = decodeURIComponent(encoded);
const markCopied = () => {
btn.classList.add('is-copied');
btn.setAttribute('aria-label', 'Copied');
setTimeout(() => {
btn.classList.remove('is-copied');
btn.setAttribute('aria-label', 'Copy code');
}, 1500);
};
if (navigator.clipboard?.writeText) {
navigator.clipboard
.writeText(code)
.then(markCopied)
.catch(() => {});
}
});
Comment on lines +301 to +307
const fenceRegex = /^```(\w+)\s*$/gm;
let match: RegExpExecArray | null;
const langs: string[] = [];
while ((match = fenceRegex.exec(text)) !== null) {
langs.push(match[1]);
}
await Promise.all(langs.map((l) => ensureLanguage(l)));
Comment on lines +10 to +15
import { CsvBlock } from './CsvBlock';
import { HtmlPreviewBlock } from './HtmlPreviewBlock';
import { MermaidBlock } from './MermaidBlock';
import { SurveyBlock } from './SurveyBlock';
import { useMarkdown } from './useMarkdown';

Comment on lines +22 to +36
export const CsvBlock: React.FC<CsvBlockProps> = ({ code, id }) => {
const [sortConfig, setSortConfig] = useState<SortConfig | null>(null);

const parsed = useMemo(() => {
try {
const result = Papa.parse(code, {
header: true,
dynamicTyping: true,
skipEmptyLines: 'greedy' as const,
}) as Papa.ParseResult<Record<string, string>>;
return { headers: result.meta.fields ?? [], rows: result.data, error: null };
} catch (err) {
return {
headers: [] as string[],
rows: [] as Record<string, string>[],
Comment on lines +26 to +33
mermaidReady = import(/* @vite-ignore */ 'mermaid').then((mod) => {
const m = (mod as { default: MermaidApi }).default;
m.initialize({
startOnLoad: false,
theme: document.documentElement.classList.contains('dark') ? 'dark' : 'default',
securityLevel: 'loose',
});
mermaidInstance = m;
Comment on lines +35 to +43
useEffect(() => {
function handleMessage(event: globalThis.MessageEvent) {
if (event.data?.type === 'HTML_PREVIEW_RESIZE' && event.data?.id === id) {
setIframeHeight(Math.min(Math.max(event.data.height, 200), 4000));
}
}
window.addEventListener('message', handleMessage);
return () => window.removeEventListener('message', handleMessage);
}, [id]);
Comment on lines +86 to +103
/* atom-one-dark (scoped under data-theme="dark") */
[data-theme='dark'] .hljs {
color: #abb2bf;
background: #282c34;
}
[data-theme='dark'] .hljs-comment,
[data-theme='dark'] .hljs-quote {
color: #5c6370;
}
[data-theme='dark'] .hljs-doctag,
[data-theme='dark'] .hljs-formula,
[data-theme='dark'] .hljs-keyword {
color: #c678dd;
}
[data-theme='dark'] .hljs-deletion,
[data-theme='dark'] .hljs-name,
[data-theme='dark'] .hljs-section,
[data-theme='dark'] .hljs-selector-tag,
Comment on lines +271 to +279
export interface UseMarkdownResult {
/** Render markdown text to sanitised HTML string */
render: (text: string, cacheKey?: string) => string;
/** Async-render that lazy-loads needed languages first */
renderAsync: (text: string, cacheKey?: string) => Promise<string>;
/** Clear the render cache */
clearCache: () => void;
}

- Render plain code fences via React portal + FenceBlock (new CodeBlock
  component) instead of injecting raw HTML + a global click listener.
  Removes COPY_BTN_HTML, the document-level click handler, and the
  .fence-block / .fence-copy-btn CSS — code blocks now use the same
  copy/raw-toggle UX as Mermaid/CSV/Survey/HTML blocks.
- useMarkdown: bound the render cache (LRU, 200 entries), reset the
  block-id counter per render, use marked's RendererThis instead of
  unsafe `unknown` casts, register js/ts/sh hljs aliases.
- MermaidBlock: drop unused containerRef.
- HtmlPreviewBlock: drop unused iframeRef and unreachable Escape branch
  in handleCloseExpanded.
- Export CodeBlock and highlightCode helper.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants