Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions packages/preview/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
// ============================================================================
Expand Down
145 changes: 145 additions & 0 deletions packages/preview/src/plugins/copy-button.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
/**
* @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<CopyButtonPluginOptions> = {
buttonText: 'Copy',
copiedText: 'Copied!',
classPrefix: 'cm-',
includeShikiBlocks: true,
};

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<string>();

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]*?)</pre>`,
'gi',
);

const copyButtonClass = `${prefix}copy-button`;
const copyTextClass = `${prefix}copy-button-text`;

const scriptKey = `${prefix}|${copyButtonClass}|${copyTextClass}`;

const injectScript = (): string => {
if (injectedScripts.has(scriptKey)) {
return '';
}
injectedScripts.add(scriptKey);

const safeButtonText = escapeStringForJS(opts.buttonText);
const safeCopiedText = escapeStringForJS(opts.copiedText);

return `<script>
(function() {
document.addEventListener('click', function(e) {
var btn = e.target.closest('.${copyButtonClass}');
if (!btn) return;
var pre = btn.closest('pre');
if (!pre) return;
var code = pre.querySelector('code');
var text = code ? code.textContent : '';
navigator.clipboard.writeText(text).then(function() {
var textEl = btn.querySelector('.${copyTextClass}');
if (textEl) textEl.textContent = '${safeCopiedText}';
btn.setAttribute('data-copied', 'true');
setTimeout(function() {
var textEl = btn.querySelector('.${copyTextClass}');
if (textEl) textEl.textContent = '${safeButtonText}';
btn.removeAttribute('data-copied');
}, 2000);
}).catch(function() {
var textEl = btn.querySelector('.${copyTextClass}');
if (textEl) textEl.textContent = 'Failed';
});
});
})();
</script>`;
};

let buttonCount = 0;

const processedHtml = html.replace(blockRegex, (match) => {
buttonCount++;

const buttonHtml = `<button class="${copyButtonClass}" type="button" aria-label="Copy code">
<span class="${copyTextClass}">${opts.buttonText}</span>
</button>`;

return `<div class="${prefix}code-wrapper">${match}${buttonHtml}</div>`;
});

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);
}
`;
},
};
}
103 changes: 103 additions & 0 deletions packages/preview/src/plugins/heading-anchors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
/**
* @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<HeadingAnchorsPluginOptions> = {
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 level = headingBlock.props.level;

const defaultHtml = defaultRender();

const anchorHtml = `<a class="${prefix}heading-anchor" href="#${anchorId}" aria-hidden="true" tabindex="-1">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M7.775 3.275a.75.75 0 0 0 1.06 1.06l1.25-1.25a2 2 0 1 1 2.83 2.83l-2.5 2.5a2 2 0 0 1-2.83 0 .75.75 0 0 0-1.06 1.06 3.5 3.5 0 0 0 4.95 0l2.5-2.5a3.5 3.5 0 0 0-4.95-4.95l-1.25 1.25zm-1.72-1.72a.75.75 0 0 0-1.06-1.06l-1.25 1.25a2 2 0 1 1-2.83-2.83l2.5-2.5a2 2 0 0 1 2.83 0 .75.75 0 1 0 1.06-1.06 3.5 3.5 0 0 0-4.95 0l-2.5 2.5a3.5 3.5 0 1 0 4.95 4.95l1.25-1.25z"/>
</svg>
</a>`;

const headingTag = `h${level}`;
const idAttr = ` id="${anchorId}"`;

const htmlWithId = defaultHtml.replace(
new RegExp(`^<${headingTag}([^>]*)>`, 'i'),
(_match, attrs) => `<${headingTag}${attrs}${idAttr}>`
);

const htmlWithAnchor = htmlWithId.replace(
new RegExp(`</${headingTag}>`, 'i'),
`${anchorHtml}</${headingTag}>`
);

return htmlWithAnchor;
},

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;
}
`;
},
};
}
108 changes: 108 additions & 0 deletions packages/preview/src/plugins/katex.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
/**
* @create-markdown/preview - KaTeX Plugin
* Math rendering using KaTeX
*/

import type { PreviewPlugin } from './types';

export interface KaTeXPluginOptions {
throwOnError?: boolean;
errorColor?: string;
macros?: Record<string, string>;
classPrefix?: string;
}

const DEFAULT_OPTIONS: Required<KaTeXPluginOptions> = {
throwOnError: false,
errorColor: '#cc0000',
macros: {},
classPrefix: 'cm-',
};

let katexModule: typeof import('katex') | null = null;

function escapeHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}

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 {
const safeExpr = escapeHtml(expr);
return `<span class="${prefix}katex-error" title="Invalid math expression">${safeExpr}</span>`;
}
};

const blockRegex = /\$\$([\s\S]+?)\$\$/g;
html = html.replace(blockRegex, (_match, expr) => {
return `<div class="${prefix}katex-block">${renderMath(expr.trim(), true)}</div>`;
});

const inlineRegex = /\$([^\$\n]+?)\$/g;
html = html.replace(inlineRegex, (_match, expr) => {
if (expr.includes('$$')) return _match;
return `<span class="${prefix}katex-inline">${renderMath(expr.trim(), false)}</span>`;
});

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;
}
`;
},
};
}
Loading