Skip to content

feat(preview): add plugins for enhanced markdown preview#6

Open
kerwin2046 wants to merge 2 commits intoBunsDev:mainfrom
kerwin2046:main
Open

feat(preview): add plugins for enhanced markdown preview#6
kerwin2046 wants to merge 2 commits intoBunsDev:mainfrom
kerwin2046:main

Conversation

@kerwin2046
Copy link
Copy Markdown

Summary

Add four new preview plugins for enhanced markdown rendering:

  • copyButtonPlugin - Adds copy-to-clipboard button to code blocks with hover reveal and "Copied!" feedback
  • headingAnchorsPlugin - Adds anchor links to headings (# icon) for deep linking and table of contents navigation
  • katexPlugin - Math rendering support using KaTeX for $inline$ and $$block$$ expressions
  • tocPlugin - Automatic table of contents generation from headings, with extractToc() and renderToc() utility functions

Motivation

These plugins address frequently requested features from the community (see ROADMAP.md):

  • Code copy buttons are essential for documentation sites and blogs
  • Heading anchors enable sharing deep links to specific sections
  • KaTeX support enables academic and technical documentation with math formulas
  • TOC generation provides navigation for long-form content

Implementation

All plugins follow the existing PreviewPlugin interface:

  • Zero new dependencies - KaTeX is loaded lazily as an optional peer dependency
  • Type-safe - Full TypeScript types for all options
  • CSS isolated - All styles use the classPrefix option (default: cm-)
  • CSP compliant - No inline scripts, uses event delegation

Usage

import { renderAsync, blocksToHTML } from '@create-markdown/preview';
import { 
  copyButtonPlugin, 
  headingAnchorsPlugin, 
  katexPlugin, 
  tocPlugin 
} from '@create-markdown/preview';
// Full async rendering with plugin initialization
const html = await renderAsync(blocks, {
  plugins: [
    tocPlugin(),
    headingAnchorsPlugin(),
    copyButtonPlugin(),
    katexPlugin(),
  ],
});
// Or use individual plugins
const html = blocksToHTML(blocks, {
  plugins: [copyButtonPlugin({ buttonText: 'Copy', copiedText: 'Copied!' })],
});
// Extract TOC items programmatically
import { extractToc, renderToc } from '@create-markdown/preview';
const items = extractToc(blocks);
const tocHtml = renderToc(items);
Testing
- TypeScript type checking passes
- Existing tests continue to pass (36 tests)
- Build succeeds with both ESM and CJS outputs
Checklist
- [x] Add plugins to packages/preview/src/index.ts exports
- [x] Add type declarations for optional peer dependency (katex)
- [x] TypeScript compilation passes
- [x] Existing tests pass
- [x] Build succeeds

@vercel
Copy link
Copy Markdown

vercel bot commented Apr 11, 2026

@kerwin2046 is attempting to deploy a commit to the 0xBuns Team on Vercel.

A member of the Team first needs to authorize it.

Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request introduces several new plugins for the markdown previewer, including KaTeX for math rendering, a copy-to-clipboard button for code blocks, heading anchors, and a table of contents generator. Feedback highlights critical security concerns regarding XSS vulnerabilities in the copy-button and KaTeX plugins due to unescaped user input. Additionally, there are logic errors in HTML attribute injection for heading anchors, concurrency issues with shared state in the TOC plugin, and accessibility concerns regarding the TOC's flat list structure.

Comment on lines +61 to +70
if (textEl) textEl.textContent = '${opts.copiedText}';
btn.setAttribute('data-copied', 'true');
setTimeout(function() {
var textEl = btn.querySelector('.${copyTextClass}');
if (textEl) textEl.textContent = '${opts.buttonText}';
btn.removeAttribute('data-copied');
}, 2000);
}).catch(function() {
var textEl = btn.querySelector('.${copyTextClass}');
if (textEl) textEl.textContent = 'Failed';
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

security-high high

There is a potential XSS vulnerability here. The opts.copiedText, opts.buttonText, and opts.classPrefix values are injected directly into a script string without escaping. If these options are ever derived from user-provided configuration, an attacker could inject arbitrary JavaScript by using single quotes and semicolons (e.g., '); alert(1); //).

Since these values are used as string literals in the generated JavaScript, you should at least escape single quotes or use JSON.stringify() to safely embed the strings.


if (parts.length !== 2) return null;

return `${parts[0]}${idAttr}${anchorHtml}${closeTag}${parts[1]}`;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

The logic for injecting the id attribute is incorrect and produces invalid HTML. By splitting on the closing tag and appending idAttr to parts[0], the attribute is placed inside the tag's content rather than within the opening tag. For example, <h1>Title</h1> becomes <h1>Title id="slug"<a...>...</a></h1>.

You should use a regex to inject the id into the opening tag.

      const htmlWithId = defaultHtml.replace(/^<h[1-6]/i, (m) => `${m}${idAttr}`);
      const closeTag = `</h${headingBlock.props.level}>`;
      return htmlWithId.replace(new RegExp(closeTag, 'i'), `${anchorHtml}${closeTag}`);

macros: opts.macros,
});
} catch {
return `<span class="${prefix}katex-error">${expr}</span>`;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

security-high high

The error fallback renders the raw expr string directly into the HTML. This is a security risk as the math expression could contain malicious HTML/script tags (e.g., $\</span><script>alert(1)</script>$). Always escape the expression before rendering it in the error span.

const opts = { ...DEFAULT_OPTIONS, ...options };
const prefix = opts.classPrefix;

const tocItems: TocItem[] = [];
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

The tocItems array is shared across all renders using this plugin instance. Since renderAsync is asynchronous, concurrent calls using the same plugin instance will lead to race conditions where headings from different documents are mixed together or cleared prematurely.

To fix this, consider passing a context object through the rendering pipeline or ensuring that state is scoped to a single render cycle.

tocItems.push({
level,
text,
anchor: slugify(text),
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

The slugify implementation here does not support an anchorPrefix, whereas the headingAnchorsPlugin does. If a user configures a prefix for their heading anchors, the TOC links generated by this plugin will be broken because they won't include that prefix. These two plugins should share a consistent slugification utility and configuration.

includeShikiBlocks: true,
};

let scriptInjected = false;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

The scriptInjected flag is defined at the module level. This causes issues if multiple preview instances are used on the same page with different classPrefix values. The script will only be injected once using the configuration of the first plugin instance. Subsequent instances with different prefixes will have buttons that do not match the event listener's selector, rendering them non-functional.

Comment on lines +97 to +102
const indent = (item.level - parentLevel) * opts.indentWidth;
const indentStyle = indent > 0 ? ` style="padding-left: ${indent}em"` : '';

result += `<li class="${prefix}${opts.itemClass}"${indentStyle}>
<a class="${prefix}${opts.linkClass}" href="#${item.anchor}">${escapeHtml(item.text)}</a>
</li>\n`;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

The Table of Contents is rendered as a flat list with inline padding for indentation. This is poor for accessibility and SEO. A semantically correct TOC should use nested <ul> elements to represent the document hierarchy. Screen readers rely on proper list nesting to communicate structure to users.

- 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 <ul> for accessibility, add anchorPrefix option
- copy-button: use Set to support multiple instances with different prefixes
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.

1 participant