From 9a9a1d617b45d475664330fa6929afa8e0631fd3 Mon Sep 17 00:00:00 2001 From: Beak Date: Sat, 11 Apr 2026 22:55:05 +0800 Subject: [PATCH 1/2] feat(preview): add plugins for enhanced markdown preview --- packages/preview/src/index.ts | 16 ++ packages/preview/src/plugins/copy-button.ts | 137 ++++++++++++ .../preview/src/plugins/heading-anchors.ts | 100 +++++++++ packages/preview/src/plugins/katex.ts | 98 ++++++++ packages/preview/src/plugins/toc.ts | 211 ++++++++++++++++++ packages/preview/src/types.d.ts | 16 ++ 6 files changed, 578 insertions(+) create mode 100644 packages/preview/src/plugins/copy-button.ts create mode 100644 packages/preview/src/plugins/heading-anchors.ts create mode 100644 packages/preview/src/plugins/katex.ts create mode 100644 packages/preview/src/plugins/toc.ts create mode 100644 packages/preview/src/types.d.ts diff --git a/packages/preview/src/index.ts b/packages/preview/src/index.ts index b1ac258..2d46c4f 100644 --- a/packages/preview/src/index.ts +++ b/packages/preview/src/index.ts @@ -53,6 +53,22 @@ export { export { shikiPlugin, createShikiPlugin } from './plugins/shiki'; export type { ShikiPluginOptions } from './plugins/shiki'; +// KaTeX plugin +export { katexPlugin } from './plugins/katex'; +export type { KaTeXPluginOptions } from './plugins/katex'; + +// Copy button plugin +export { copyButtonPlugin } from './plugins/copy-button'; +export type { CopyButtonPluginOptions } from './plugins/copy-button'; + +// Heading anchors plugin +export { headingAnchorsPlugin } from './plugins/heading-anchors'; +export type { HeadingAnchorsPluginOptions } from './plugins/heading-anchors'; + +// Table of contents plugin +export { tocPlugin, extractToc, renderToc } from './plugins/toc'; +export type { TocPluginOptions, TocItem } from './plugins/toc'; + // ============================================================================ // Web Component // ============================================================================ diff --git a/packages/preview/src/plugins/copy-button.ts b/packages/preview/src/plugins/copy-button.ts new file mode 100644 index 0000000..8eb2782 --- /dev/null +++ b/packages/preview/src/plugins/copy-button.ts @@ -0,0 +1,137 @@ +/** + * @create-markdown/preview - Copy Button Plugin + * Adds copy-to-clipboard button to code blocks + */ + +import type { PreviewPlugin } from './types'; + +export interface CopyButtonPluginOptions { + buttonText?: string; + copiedText?: string; + classPrefix?: string; + includeShikiBlocks?: boolean; +} + +const DEFAULT_OPTIONS: Required = { + buttonText: 'Copy', + copiedText: 'Copied!', + classPrefix: 'cm-', + includeShikiBlocks: true, +}; + +let scriptInjected = false; + +export function copyButtonPlugin(options?: CopyButtonPluginOptions): PreviewPlugin { + const opts = { ...DEFAULT_OPTIONS, ...options }; + const prefix = opts.classPrefix; + + return { + name: 'copy-button', + + postProcess(html: string): string { + const blockSelector = opts.includeShikiBlocks + ? `pre\\.${prefix}code-block|pre\\.${prefix}shiki` + : `pre\\.${prefix}code-block`; + + const blockRegex = new RegExp( + `<(${blockSelector})([^>]*)>([\\s\\S]*?)`, + 'gi', + ); + + const copyButtonClass = `${prefix}copy-button`; + const copyTextClass = `${prefix}copy-button-text`; + + const injectScript = (): string => { + if (scriptInjected) { + return ''; + } + scriptInjected = true; + + return ``; + }; + + let buttonCount = 0; + + const processedHtml = html.replace(blockRegex, (match) => { + buttonCount++; + const copyId = `copy-${Date.now()}-${buttonCount}`; + + const buttonHtml = ``; + + return `
${match}${buttonHtml}
`; + }); + + if (buttonCount > 0) { + return processedHtml + injectScript(); + } + + return html; + }, + + getCSS(): string { + return ` +.${prefix}code-wrapper { + position: relative; +} + +.${prefix}copy-button { + position: absolute; + top: 8px; + right: 8px; + padding: 4px 8px; + font-size: 12px; + font-family: ui-system, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + color: #6e7781; + background: rgba(175, 184, 193, 0.2); + border: 1px solid rgba(31, 35, 40, 0.15); + border-radius: 6px; + cursor: pointer; + opacity: 0; + transition: opacity 0.2s, background 0.2s; +} + +.${prefix}code-wrapper:hover .${prefix}copy-button, +.${prefix}copy-button:focus { + opacity: 1; +} + +.${prefix}copy-button:hover { + background: rgba(175, 184, 193, 0.35); + color: #24292f; +} + +.${prefix}copy-button[data-copied="true"] { + color: #0969da; + background: rgba(9, 105, 218, 0.1); + border-color: rgba(9, 105, 218, 0.3); +} +`; + }, + }; +} diff --git a/packages/preview/src/plugins/heading-anchors.ts b/packages/preview/src/plugins/heading-anchors.ts new file mode 100644 index 0000000..74ba6b6 --- /dev/null +++ b/packages/preview/src/plugins/heading-anchors.ts @@ -0,0 +1,100 @@ +/** + * @create-markdown/preview - Heading Anchors Plugin + * Adds anchor links to heading elements + */ + +import type { HeadingBlock, Block } from '@create-markdown/core'; +import type { PreviewPlugin } from './types'; + +export interface HeadingAnchorsPluginOptions { + classPrefix?: string; + anchorPrefix?: string; +} + +const DEFAULT_OPTIONS: Required = { + classPrefix: 'cm-', + anchorPrefix: '', +}; + +function slugify(text: string, prefix: string): string { + return ( + prefix + + text + .toLowerCase() + .replace(/[^\w\s-]/g, '') + .replace(/\s+/g, '-') + .replace(/-+/g, '-') + .trim() + ); +} + +export function headingAnchorsPlugin(options?: HeadingAnchorsPluginOptions): PreviewPlugin { + const opts = { ...DEFAULT_OPTIONS, ...options }; + const prefix = opts.classPrefix; + + return { + name: 'heading-anchors', + + renderBlock(block: Block, defaultRender: () => string): string | null { + if (block.type !== 'heading') { + return null; + } + + const headingBlock = block as HeadingBlock; + const content = headingBlock.content + .map((span) => span.text) + .join(''); + const anchorId = slugify(content, opts.anchorPrefix); + + const defaultHtml = defaultRender(); + + const anchorHtml = ``; + + const idAttr = ` id="${anchorId}"`; + + const match = defaultHtml.match(/^<(h[1-6])/i); + if (!match) return null; + + const tag = match[1]; + const closeTag = ``; + const parts = defaultHtml.split(closeTag); + + if (parts.length !== 2) return null; + + return `${parts[0]}${idAttr}${anchorHtml}${closeTag}${parts[1]}`; + }, + + getCSS(): string { + return ` +.${prefix}heading { + position: relative; +} + +.${prefix}heading-anchor { + position: absolute; + left: -24px; + color: #6e7781; + text-decoration: none; + opacity: 0; + transition: opacity 0.15s; + display: flex; + align-items: center; + height: 100%; +} + +.${prefix}heading:hover .${prefix}heading-anchor, +.${prefix}heading-anchor:focus { + opacity: 1; +} + +.${prefix}heading-anchor:hover { + color: #0969da; +} +`; + }, + }; +} diff --git a/packages/preview/src/plugins/katex.ts b/packages/preview/src/plugins/katex.ts new file mode 100644 index 0000000..bfffac9 --- /dev/null +++ b/packages/preview/src/plugins/katex.ts @@ -0,0 +1,98 @@ +/** + * @create-markdown/preview - KaTeX Plugin + * Math rendering using KaTeX + */ + +import type { PreviewPlugin } from './types'; + +export interface KaTeXPluginOptions { + throwOnError?: boolean; + errorColor?: string; + macros?: Record; + classPrefix?: string; +} + +const DEFAULT_OPTIONS: Required = { + throwOnError: false, + errorColor: '#cc0000', + macros: {}, + classPrefix: 'cm-', +}; + +let katexModule: typeof import('katex') | null = null; + +export function katexPlugin(options?: KaTeXPluginOptions): PreviewPlugin { + const opts = { ...DEFAULT_OPTIONS, ...options }; + const prefix = opts.classPrefix; + + return { + name: 'katex', + + async init() { + try { + katexModule = await import('katex'); + } catch { + console.warn( + '@create-markdown/preview: KaTeX not available. Install with: pnpm add katex', + ); + } + }, + + postProcess(html: string): string { + if (!katexModule) { + return html; + } + + const { renderToString, renderToStringForMarkup } = katexModule; + + const renderMath = (expr: string, displayMode: boolean): string => { + try { + const renderFn = renderToStringForMarkup || renderToString; + return renderFn(expr, { + displayMode, + throwOnError: opts.throwOnError, + errorColor: opts.errorColor, + macros: opts.macros, + }); + } catch { + return `${expr}`; + } + }; + + const blockRegex = /\$\$([\s\S]+?)\$\$/g; + html = html.replace(blockRegex, (_match, expr) => { + return `
${renderMath(expr.trim(), true)}
`; + }); + + const inlineRegex = /\$([^\$\n]+?)\$/g; + html = html.replace(inlineRegex, (_match, expr) => { + if (expr.includes('$$')) return _match; + return `${renderMath(expr.trim(), false)}`; + }); + + return html; + }, + + getCSS(): string { + return ` +.${prefix}katex-inline, +.${prefix}katex-block { + font-family: 'KaTeX_Main', 'Times New Roman', serif; +} + +.${prefix}katex-block { + display: block; + text-align: center; + margin: 1em 0; + overflow-x: auto; + overflow-y: hidden; +} + +.${prefix}katex-error { + color: ${opts.errorColor}; + font-family: monospace; +} +`; + }, + }; +} diff --git a/packages/preview/src/plugins/toc.ts b/packages/preview/src/plugins/toc.ts new file mode 100644 index 0000000..869ec87 --- /dev/null +++ b/packages/preview/src/plugins/toc.ts @@ -0,0 +1,211 @@ +/** + * @create-markdown/preview - Table of Contents Plugin + * Generates a table of contents from heading blocks + */ + +import type { HeadingBlock, Block } from '@create-markdown/core'; +import type { PreviewPlugin } from './types'; + +export interface TocPluginOptions { + classPrefix?: string; + containerClass?: string; + listClass?: string; + itemClass?: string; + linkClass?: string; + indentWidth?: number; + headingLevels?: number[]; +} + +export interface TocItem { + level: number; + text: string; + anchor: string; +} + +const DEFAULT_OPTIONS: Required = { + classPrefix: 'cm-', + containerClass: 'toc', + listClass: 'toc-list', + itemClass: 'toc-item', + linkClass: 'toc-link', + indentWidth: 2, + headingLevels: [1, 2, 3, 4, 5, 6], +}; + +function slugify(text: string): string { + return text + .toLowerCase() + .replace(/[^\w\s-]/g, '') + .replace(/\s+/g, '-') + .replace(/-+/g, '-') + .trim(); +} + +function escapeHtml(text: string): string { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +export function tocPlugin(options?: TocPluginOptions): PreviewPlugin { + const opts = { ...DEFAULT_OPTIONS, ...options }; + const prefix = opts.classPrefix; + + const tocItems: TocItem[] = []; + + return { + name: 'table-of-contents', + + transformBlock(block: Block): Block { + if (block.type === 'heading') { + const headingBlock = block as HeadingBlock; + const level = headingBlock.props.level; + + if (opts.headingLevels.includes(level)) { + const text = headingBlock.content.map((span) => span.text).join(''); + tocItems.push({ + level, + text, + anchor: slugify(text), + }); + } + } + return block; + }, + + postProcess(html: string): string { + if (tocItems.length === 0) { + return html; + } + + const buildList = (items: TocItem[], parentLevel = 1): string => { + let result = ''; + let currentItem: TocItem | null = null; + + for (const item of items) { + if (item.level < parentLevel) continue; + + if (!currentItem) { + result += `
    \n`; + } + + currentItem = item; + + const indent = (item.level - parentLevel) * opts.indentWidth; + const indentStyle = indent > 0 ? ` style="padding-left: ${indent}em"` : ''; + + result += `
  • + ${escapeHtml(item.text)} +
  • \n`; + } + + if (currentItem) { + result += '
\n'; + } + + return result; + }; + + const minLevel = Math.min(...tocItems.map((i) => i.level)); + const tocHtml = `
+${buildList(tocItems, minLevel)}
\n`; + + tocItems.length = 0; + + return tocHtml + html; + }, + + getCSS(): string { + return ` +.${prefix}${opts.containerClass}[data-toc="true"] { + margin-bottom: 1.5em; + padding: 1em; + background: rgba(175, 184, 193, 0.1); + border-radius: 6px; +} + +.${prefix}${opts.listClass} { + margin: 0; + padding-left: 1.5em; + list-style: none; +} + +.${prefix}${opts.itemClass} { + margin: 0.25em 0; +} + +.${prefix}${opts.linkClass} { + color: #0969da; + text-decoration: none; + font-size: 0.9em; +} + +.${prefix}${opts.linkClass}:hover { + text-decoration: underline; +} +`; + }, + }; +} + +export function extractToc(blocks: Block[]): TocItem[] { + const items: TocItem[] = []; + + for (const block of blocks) { + if (block.type === 'heading') { + const headingBlock = block as HeadingBlock; + const level = headingBlock.props.level; + const text = headingBlock.content.map((span) => span.text).join(''); + + items.push({ + level, + text, + anchor: slugify(text), + }); + } + } + + return items; +} + +export function renderToc(items: TocItem[], classPrefix = 'cm-'): string { + if (items.length === 0) { + return ''; + } + + const minLevel = Math.min(...items.map((i) => i.level)); + + const buildList = (items: TocItem[], parentLevel: number): string => { + let result = ''; + let currentItem: TocItem | null = null; + + for (const item of items) { + if (item.level < parentLevel) continue; + + if (!currentItem) { + result += `
    \n`; + } + + currentItem = item; + + const indent = (item.level - parentLevel) * 2; + const indentStyle = indent > 0 ? ` style="padding-left: ${indent}em"` : ''; + + result += `
  • + ${escapeHtml(item.text)} +
  • \n`; + } + + if (currentItem) { + result += '
\n'; + } + + return result; + }; + + return `
+${buildList(items, minLevel)}
`; +} diff --git a/packages/preview/src/types.d.ts b/packages/preview/src/types.d.ts new file mode 100644 index 0000000..734bfcd --- /dev/null +++ b/packages/preview/src/types.d.ts @@ -0,0 +1,16 @@ +/** + * Type declarations for optional peer dependencies + * These modules may not be installed - they are loaded lazily at runtime + */ + +declare module 'katex' { + export interface RenderOptions { + displayMode?: boolean; + throwOnError?: boolean; + errorColor?: string; + macros?: Record; + } + + export function renderToString(expr: string, options?: RenderOptions): string; + export const renderToStringForMarkup: ((expr: string, options?: RenderOptions) => string) | undefined; +} From 085afea56e0899b944049eeec92f2303f3c651cb Mon Sep 17 00:00:00 2001 From: Beak Date: Sat, 11 Apr 2026 23:09:04 +0800 Subject: [PATCH 2/2] fix(preview): address code review security and accessibility issues - copy-button: escape strings for JS to prevent XSS - heading-anchors: fix HTML injection logic for id attribute - katex: escape error fallback expressions to prevent XSS - toc: use nested
    for accessibility, add anchorPrefix option - copy-button: use Set to support multiple instances with different prefixes --- packages/preview/src/plugins/copy-button.ts | 22 ++- .../preview/src/plugins/heading-anchors.ts | 19 ++- packages/preview/src/plugins/katex.ts | 12 +- packages/preview/src/plugins/toc.ts | 157 ++++++++++++------ 4 files changed, 140 insertions(+), 70 deletions(-) diff --git a/packages/preview/src/plugins/copy-button.ts b/packages/preview/src/plugins/copy-button.ts index 8eb2782..dd37b27 100644 --- a/packages/preview/src/plugins/copy-button.ts +++ b/packages/preview/src/plugins/copy-button.ts @@ -19,12 +19,16 @@ const DEFAULT_OPTIONS: Required = { includeShikiBlocks: true, }; -let scriptInjected = false; +function escapeStringForJS(str: string): string { + return str.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/\n/g, '\\n').replace(/\r/g, '\\r'); +} export function copyButtonPlugin(options?: CopyButtonPluginOptions): PreviewPlugin { const opts = { ...DEFAULT_OPTIONS, ...options }; const prefix = opts.classPrefix; + const injectedScripts = new Set(); + return { name: 'copy-button', @@ -41,11 +45,16 @@ export function copyButtonPlugin(options?: CopyButtonPluginOptions): PreviewPlug const copyButtonClass = `${prefix}copy-button`; const copyTextClass = `${prefix}copy-button-text`; + const scriptKey = `${prefix}|${copyButtonClass}|${copyTextClass}`; + const injectScript = (): string => { - if (scriptInjected) { + if (injectedScripts.has(scriptKey)) { return ''; } - scriptInjected = true; + injectedScripts.add(scriptKey); + + const safeButtonText = escapeStringForJS(opts.buttonText); + const safeCopiedText = escapeStringForJS(opts.copiedText); return `