tessl install tessl/npm-marked@17.0.0A markdown parser built for speed
Marked's extension system allows you to customize parsing and rendering behavior by adding custom tokenizers, renderers, and processing hooks.
| Extension Type | Purpose | Level | Returns |
|---|---|---|---|
| Tokenizer Extension | Add new syntax | 'block' or 'inline' | Token object or undefined |
| Renderer Extension | Custom HTML output | - | HTML string or false |
| Tokenizer Override | Modify built-in syntax | - | Token object or false |
| Renderer Override | Modify built-in output | - | HTML string or false |
| Hooks | Intercept processing | - | Processed content |
| walkTokens | Process all tokens | - | void or Promise<void> |
/**
* Apply one or more extensions to marked
* Extensions are applied in order and later extensions can override earlier ones
* @param extensions - Extension objects to apply
* @returns The marked function for chaining
*/
function use(...extensions: MarkedExtension[]): typeof marked;Extensions are applied in the order they are passed and can be chained:
import { marked } from "marked";
marked.use(extension1)
.use(extension2, extension3)
.use(extension4);Important: Extensions are additive - calling use() multiple times adds to existing extensions rather than replacing them.
Extensions and options are applied in this order (later overrides earlier):
setOptions()use() (in order called)import { marked } from "marked";
// 1. Built-in defaults
// gfm: true, breaks: false
// 2. Global options
marked.setOptions({ breaks: false, gfm: true });
// 3. Extensions (override global options)
marked.use({ breaks: true });
// 4. Parse-time options (override everything)
const html = marked.parse(markdown, { breaks: false });Add new syntax patterns that marked should recognize and convert into custom tokens.
interface TokenizerExtension {
/**
* Name of the extension (becomes token type)
* Must be unique - will override existing token types with same name
*/
name: string;
/**
* Whether this is a block-level or inline-level tokenizer
* block: for elements like paragraphs, headings, lists
* inline: for elements within text like emphasis, links
*/
level: 'block' | 'inline';
/**
* Optional function to quickly find where this token might start
* Performance optimization - return the index to begin trying tokenization
* @param src - Remaining source text
* @returns Index where token might start, or void if not found
*/
start?: (src: string) => number | void;
/**
* Function to tokenize the custom syntax
* Called when src is at a position where this token might begin
* @param src - Source text starting at potential token
* @param tokens - Current token array (for context, usually not modified)
* @returns Custom token object or undefined if no match
* Token must include: type, raw, and any custom properties
*/
tokenizer: (src: string, tokens: Token[] | TokensList) => Tokens.Generic | undefined;
/**
* Optional array of property names containing child tokens
* Used by walkTokens to traverse the token tree
* Example: ['tokens'] if your token has a 'tokens' property with nested tokens
*/
childTokens?: string[];
}Example - Custom Emoji Syntax:
import { marked } from "marked";
marked.use({
extensions: [{
name: 'emoji',
level: 'inline',
start(src) {
// Performance optimization: quickly find potential start
return src.indexOf(':');
},
tokenizer(src, tokens) {
// Match :emoji_name: pattern
const rule = /^:([a-z_]+):/;
const match = rule.exec(src);
if (match) {
return {
type: 'emoji',
raw: match[0], // Required: original matched text
name: match[1] // Custom property
};
}
// Return undefined if no match
},
renderer(token) {
return `<span class="emoji emoji-${token.name}" role="img" aria-label="${token.name}"></span>`;
}
}]
});
const html = marked.parse('Hello :wave: world!');
// Output: <p>Hello <span class="emoji emoji-wave" role="img" aria-label="wave"></span> world!</p>Tokenizer Best Practices:
undefined (not null or false) when no matchraw property in returned tokenstart() function for performance with large documentssrc (use ^ anchor in regex)this.lexer.inlineTokens() or this.lexer.blockTokens()Common Tokenizer Mistakes:
// ❌ BAD: Returning null/false instead of undefined
tokenizer(src) {
const match = src.match(/^pattern/);
if (!match) {
return false; // WRONG! Should be undefined
}
return { type: 'custom', raw: match[0] };
}
// ✓ GOOD: Returning undefined
tokenizer(src) {
const match = src.match(/^pattern/);
if (!match) {
return undefined; // or just return;
}
return { type: 'custom', raw: match[0] };
}
// ❌ BAD: Missing raw property
tokenizer(src) {
const match = src.match(/^pattern/);
if (match) {
return { type: 'custom' }; // Missing raw!
}
}
// ✓ GOOD: Including raw property
tokenizer(src) {
const match = src.match(/^pattern/);
if (match) {
return { type: 'custom', raw: match[0] }; // Required
}
}
// ❌ BAD: Not matching from start of src
tokenizer(src) {
const match = src.match(/pattern/); // Missing ^ anchor
// This can match anywhere in src, not just at start
}
// ✓ GOOD: Matching from start
tokenizer(src) {
const match = src.match(/^pattern/); // Matches at start only
}import { marked } from "marked";
// Example: Custom alert boxes with nested markdown
marked.use({
extensions: [{
name: 'alert',
level: 'block',
start(src) {
return src.match(/^:::/)?.index;
},
tokenizer(src) {
// Match :::type\ncontent\n::: syntax
const rule = /^:::(\w+)\n([\s\S]*?)\n:::/;
const match = rule.exec(src);
if (match) {
return {
type: 'alert',
raw: match[0],
alertType: match[1],
text: match[2],
// Recursively tokenize nested content
tokens: this.lexer.blockTokens(match[2])
};
}
},
renderer(token) {
// Parse nested tokens
const body = this.parser.parse(token.tokens);
return `<div class="alert alert-${token.alertType}" role="alert">${body}</div>\n`;
}
}],
// Tell walkTokens about nested tokens
childTokens: ['tokens']
});
// Usage:
// :::warning
// This is a **warning** with *markdown*.
// :::Add custom rendering for tokenizer extensions or override built-in token rendering.
interface RendererExtension {
/**
* Name matching the token type to render
* Must match a token type (built-in or from tokenizer extension)
*/
name: string;
/**
* Function to render the token to output
* @param token - The token to render
* @returns Rendered output string, or false to use default renderer
* Returning false allows fallback to next renderer or default
*/
renderer: (token: Tokens.Generic) => string | false;
}Example - Custom Blockquote Renderer:
import { marked } from "marked";
marked.use({
extensions: [{
name: 'blockquote',
renderer(token) {
// Access parser to render nested tokens
const body = this.parser.parse(token.tokens);
// Detect blockquote type from first line
const firstLine = body.match(/<p>(.+?)<\/p>/)?.[1] || '';
let type = 'default';
let content = body;
if (firstLine.startsWith('Note:')) {
type = 'note';
content = body.replace(/<p>Note:\s*/, '<p>');
} else if (firstLine.startsWith('Warning:')) {
type = 'warning';
content = body.replace(/<p>Warning:\s*/, '<p>');
} else if (firstLine.startsWith('Tip:')) {
type = 'tip';
content = body.replace(/<p>Tip:\s*/, '<p>');
}
return `<blockquote class="callout callout-${type}">${content}</blockquote>\n`;
}
}]
});
// Usage:
// > Note: This is an informational note.
// > Warning: Be careful with this.Renderer Context:
this.parser: Access to parser instancethis.parser.parse(tokens): Render block tokensthis.parser.parseInline(tokens): Render inline tokensfalse to fall back to default or next rendererRenderer with Error Handling:
import { marked } from "marked";
marked.use({
renderer: {
code({ text, lang, escaped }) {
try {
// Attempt custom highlighting
if (lang === 'mermaid') {
return `<div class="mermaid">${text}</div>\n`;
}
// Attempt syntax highlighting
if (lang && highlighter.getLanguage(lang)) {
const highlighted = highlighter.highlight(text, { language: lang }).value;
return `<pre><code class="hljs language-${lang}">${highlighted}</code></pre>\n`;
}
} catch (err) {
// Log error but don't break rendering
console.error(`Code rendering error for language ${lang}:`, err);
}
// Fallback to default
return false;
}
}
});A single extension can provide both tokenizer and renderer:
type TokenizerAndRendererExtension =
| TokenizerExtension
| RendererExtension
| (TokenizerExtension & RendererExtension);Example - YouTube Embed:
import { marked } from "marked";
marked.use({
extensions: [{
name: 'youtube',
level: 'block',
start(src) {
return src.match(/^@\[youtube\]/)?.index;
},
tokenizer(src) {
// Match @[youtube](videoId) syntax
const rule = /^@\[youtube\]\(([\w-]+)\)(\s*{([^}]+)})?\n?/;
const match = rule.exec(src);
if (match) {
// Parse optional attributes
const attrs = match[3] ? match[3].split(/\s+/) : [];
const width = attrs.find(a => a.startsWith('width='))?.split('=')[1] || '560';
const height = attrs.find(a => a.startsWith('height='))?.split('=')[1] || '315';
return {
type: 'youtube',
raw: match[0],
videoId: match[1],
width,
height
};
}
},
renderer(token) {
return `<div class="video-wrapper">
<iframe
width="${token.width}"
height="${token.height}"
src="https://www.youtube.com/embed/${token.videoId}"
frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen
loading="lazy">
</iframe>
</div>\n`;
}
}]
});
// Usage:
// @[youtube](dQw4w9WgXcQ)
// @[youtube](dQw4w9WgXcQ) {width=800 height=600}Override built-in renderer methods without creating custom tokens.
import { marked } from "marked";
marked.use({
renderer: {
// Override heading rendering with IDs and anchor links
heading({ tokens, depth }) {
const text = this.parser.parseInline(tokens);
const id = text
.toLowerCase()
.replace(/[^\w\s-]/g, '')
.replace(/\s+/g, '-');
return `<h${depth} id="${id}">
<a href="#${id}" class="anchor" aria-hidden="true">
<span class="icon icon-link"></span>
</a>
${text}
</h${depth}>\n`;
},
// Override link rendering for external links
link({ href, title, tokens }) {
const text = this.parser.parseInline(tokens);
const titleAttr = title ? ` title="${escapeHtml(title)}"` : '';
// Add target="_blank" for external links
const isExternal = href.startsWith('http://') || href.startsWith('https://') || href.startsWith('//');
const external = isExternal ? ' target="_blank" rel="noopener noreferrer"' : '';
// Add icon for external links
const icon = isExternal ? ' <span class="external-link-icon" aria-hidden="true">↗</span>' : '';
return `<a href="${escapeHtml(href)}"${titleAttr}${external}>${text}${icon}</a>`;
},
// Override code rendering with language badge
code({ text, lang, escaped }) {
const code = escaped ? text : escapeHtml(text);
const language = lang || 'text';
const langBadge = lang ? `<div class="code-lang">${escapeHtml(lang)}</div>` : '';
return `<div class="code-block">
${langBadge}
<pre><code class="language-${escapeHtml(language)}">${code}</code></pre>
</div>\n`;
},
// Override image rendering with lazy loading and figure wrapper
image({ href, title, text }) {
const alt = text ? ` alt="${escapeHtml(text)}"` : '';
const titleAttr = title ? ` title="${escapeHtml(title)}"` : '';
if (title) {
// Wrap in figure with caption
return `<figure class="image-figure">
<img src="${escapeHtml(href)}"${alt}${titleAttr} loading="lazy">
<figcaption>${escapeHtml(title)}</figcaption>
</figure>`;
}
return `<img src="${escapeHtml(href)}"${alt} loading="lazy">`;
},
// Returning false falls back to default renderer
table(token) {
// Only apply custom rendering to small tables
if (token.header.length > 10) {
return false; // Use default for large tables
}
// Custom rendering for small tables
// ... custom logic ...
return false; // Or return custom HTML
}
}
});
function escapeHtml(html) {
return String(html)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}Renderer Override Notes:
this.parser.parseInline() or this.parser.parse() for nested tokensfalse to use default rendererthis.options for current configurationOverride built-in tokenizer methods to customize recognition of standard markdown elements.
import { marked } from "marked";
marked.use({
tokenizer: {
// Override code block tokenization to support custom attributes
fences(src) {
// Match ```lang [filename] {attrs}
const match = src.match(/^```(\w+)?(?:\s+\[([^\]]+)\])?(?:\s+\{([^}]+)\})?\n([\s\S]*?)\n```/);
if (match) {
return {
type: 'code',
raw: match[0],
lang: match[1] || '',
filename: match[2] || null, // Custom property
attributes: match[3] || null, // Custom property
text: match[4]
};
}
return false; // Use default if no match
},
// Override heading tokenization to add custom metadata
heading(src) {
// Match heading with optional {#id .class} syntax
const match = src.match(/^(#{1,6})\s+(.+?)(?:\s+\{([^}]+)\})?\s*$/);
if (match) {
const attrs = match[3];
let customId = null;
let customClasses = [];
if (attrs) {
// Parse {#id .class1 .class2}
customId = attrs.match(/#([\w-]+)/)?.[1];
customClasses = [...attrs.matchAll(/\.([\w-]+)/g)].map(m => m[1]);
}
return {
type: 'heading',
raw: match[0],
depth: match[1].length,
text: match[2],
tokens: this.lexer.inline(match[2]),
customId, // Custom property
customClasses // Custom property
};
}
return false;
},
// Override link tokenization to detect anchor links
link(src) {
// Let default tokenizer handle most of the work
// We'll just add a flag for anchor links
const match = src.match(/^!?\[([^\]]*)\]\(([^)]+)\)/);
if (match && match[2].startsWith('#')) {
return {
type: 'link',
raw: match[0],
href: match[2],
title: null,
text: match[1],
tokens: this.lexer.inlineTokens(match[1]),
isAnchor: true // Custom property
};
}
return false; // Use default tokenizer
}
}
});
// Add corresponding renderer
marked.use({
renderer: {
code({ text, lang, filename, attributes }) {
const code = escapeHtml(text);
const langClass = lang ? ` class="language-${lang}"` : '';
const fileLabel = filename ? `<div class="code-filename">${escapeHtml(filename)}</div>` : '';
return `<div class="code-block" ${attributes || ''}>
${fileLabel}
<pre><code${langClass}>${code}</code></pre>
</div>\n`;
},
heading({ tokens, depth, customId, customClasses }) {
const text = this.parser.parseInline(tokens);
const id = customId || text.toLowerCase().replace(/[^\w]+/g, '-');
const classes = customClasses?.length ? ` class="${customClasses.join(' ')}"` : '';
return `<h${depth} id="${id}"${classes}>${text}</h${depth}>\n`;
},
link({ href, title, tokens, isAnchor }) {
const text = this.parser.parseInline(tokens);
const titleAttr = title ? ` title="${escapeHtml(title)}"` : '';
const anchorClass = isAnchor ? ' class="anchor-link"' : '';
return `<a href="${escapeHtml(href)}"${titleAttr}${anchorClass}>${text}</a>`;
}
}
});
function escapeHtml(html) {
return String(html)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
// Usage:
// ```javascript [example.js] {data-line="1-5"}
// code here
// ```
//
// # Heading {#custom-id .special}
//
// [Jump to section](#custom-id)Tokenizer Override Notes:
false or undefined if notthis.lexer for recursive tokenizationthis.options for configurationthis.lexer.inline() and this.lexer.blockTokens()Process all tokens before they are rendered, useful for global modifications or validations.
/**
* Walk through all tokens including nested tokens
* Executes callback for every token in the tree
* @param tokens - Token array or TokensList
* @param callback - Function called for each token (can modify token in place)
* @returns Array of callback return values
*/
function walkTokens(
tokens: Token[] | TokensList,
callback: (token: Token) => void | Promise<void> | (void | Promise<void>)[]
): (void | Promise<void>)[];Example - Add IDs to Headings:
import { marked } from "marked";
const headingIds = new Set();
marked.use({
walkTokens(token) {
if (token.type === 'heading') {
// Generate unique ID from text
let id = token.text
.toLowerCase()
.replace(/[^\w\s-]/g, '')
.replace(/\s+/g, '-');
// Ensure uniqueness
let counter = 1;
let uniqueId = id;
while (headingIds.has(uniqueId)) {
uniqueId = `${id}-${counter++}`;
}
headingIds.add(uniqueId);
token.id = uniqueId; // Add custom property
}
},
renderer: {
heading({ tokens, depth, id }) {
const text = this.parser.parseInline(tokens);
const idAttr = id ? ` id="${id}"` : '';
return `<h${depth}${idAttr}>${text}</h${depth}>\n`;
}
}
});
// Clear IDs before each parse (if reusing marked instance)
headingIds.clear();
const html = marked.parse(markdown);Example - Async Token Processing:
import { marked } from "marked";
// Cache for link validation
const linkValidation = new Map();
async function validateUrl(url) {
if (linkValidation.has(url)) {
return linkValidation.get(url);
}
try {
const response = await fetch(url, { method: 'HEAD', timeout: 5000 });
const isValid = response.ok;
linkValidation.set(url, isValid);
return isValid;
} catch (err) {
console.warn(`Failed to validate link: ${url}`, err.message);
linkValidation.set(url, false);
return false;
}
}
marked.use({
async: true,
async walkTokens(token) {
if (token.type === 'link') {
// Async operation: validate link
const isValid = await validateUrl(token.href);
token.validated = isValid;
if (!isValid) {
console.warn(`Invalid link detected: ${token.href}`);
}
}
},
renderer: {
link(token) {
const text = this.parser.parseInline(token.tokens);
const titleAttr = token.title ? ` title="${escapeHtml(token.title)}"` : '';
if (token.validated === false) {
return `<span class="broken-link" title="Link validation failed">${text}</span>`;
}
return `<a href="${escapeHtml(token.href)}"${titleAttr}>${text}</a>`;
}
}
});
// Must use await with async mode
const html = await marked.parse('[Link](https://example.com)', { async: true });
function escapeHtml(html) {
return String(html)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}WalkTokens Best Practices:
async: true and use async/awaitHooks intercept and modify content at key points in the processing pipeline.
import { marked } from "marked";
marked.use({
hooks: {
// Process markdown before parsing
preprocess(markdown) {
// Replace custom shortcodes
return markdown
.replace(/\{DATE\}/g, new Date().toLocaleDateString())
.replace(/\{VERSION\}/g, process.env.npm_package_version || '1.0.0')
.replace(/\{YEAR\}/g, new Date().getFullYear().toString())
.replace(/-->/g, '→')
.replace(/<--/g, '←');
},
// Process HTML after parsing
postprocess(html) {
// Add wrapper or modify output
return html
.replace(/<h1>/g, '<h1 class="title">')
.replace(/<table>/g, '<div class="table-wrapper"><table class="table">')
.replace(/<\/table>/g, '</table></div>')
.replace(/<img /g, '<img loading="lazy" ');
},
// Process all tokens after lexing but before walkTokens
processAllTokens(tokens) {
// Add metadata to all tokens
const timestamp = Date.now();
function addMetadata(tokens) {
tokens.forEach(token => {
token._processed = timestamp;
token._id = generateUniqueId();
// Recursively process nested tokens
if (token.tokens) {
addMetadata(token.tokens);
}
// Handle list items
if (token.type === 'list' && token.items) {
token.items.forEach(item => {
if (item.tokens) {
addMetadata(item.tokens);
}
});
}
});
}
addMetadata(tokens);
return tokens;
}
}
});
let idCounter = 0;
function generateUniqueId() {
return `token-${Date.now()}-${idCounter++}`;
}Within tokenizer and renderer functions, this provides context:
interface TokenizerThis {
/**
* Reference to the lexer instance
* Use for recursive tokenization of nested content
*/
lexer: Lexer;
/**
* Tokenizer options
*/
options: MarkedOptions;
}Usage:
tokenizer(src, tokens) {
const match = src.match(/^custom syntax/);
if (match) {
// Access lexer for nested tokenization
const inlineTokens = this.lexer.inlineTokens('some text');
// Access options
if (this.options.gfm) {
// GFM-specific handling
}
return {
type: 'custom',
raw: match[0],
tokens: inlineTokens
};
}
}interface RendererThis {
/**
* Reference to the parser instance
* Use for rendering nested tokens
*/
parser: Parser;
/**
* Renderer options
*/
options: MarkedOptions;
}Usage:
renderer(token) {
// Parse nested block tokens
const html = this.parser.parse(token.tokens);
// Parse inline tokens
const inline = this.parser.parseInline(token.tokens);
// Access options
if (this.options.breaks) {
// Handle line breaks differently
}
return `<div>${html}</div>`;
}Multiple extensions can handle the same token type. They are applied in order, and if one returns false, the next is tried:
import { marked } from "marked";
// First extension - handles mailto links
marked.use({
renderer: {
link(token) {
if (token.href.startsWith('mailto:')) {
const email = token.href.slice(7);
const text = this.parser.parseInline(token.tokens);
return `<a href="mailto:${escapeHtml(email)}" class="email-link">📧 ${text}</a>`;
}
return false; // Pass to next renderer
}
}
});
// Second extension - handles tel links
marked.use({
renderer: {
link(token) {
if (token.href.startsWith('tel:')) {
const phone = token.href.slice(4);
const text = this.parser.parseInline(token.tokens);
return `<a href="tel:${escapeHtml(phone)}" class="phone-link">📞 ${text}</a>`;
}
return false; // Pass to next renderer
}
}
});
// Third extension - handles external links
marked.use({
renderer: {
link(token) {
const isExternal = token.href.startsWith('http://') || token.href.startsWith('https://');
if (isExternal) {
const text = this.parser.parseInline(token.tokens);
const titleAttr = token.title ? ` title="${escapeHtml(token.title)}"` : '';
return `<a href="${escapeHtml(token.href)}"${titleAttr} target="_blank" rel="noopener noreferrer">${text} ↗</a>`;
}
return false; // Pass to default renderer
}
}
});
// Execution order: email check → phone check → external check → default renderer
function escapeHtml(html) {
return String(html)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}Stacking Rules:
false passes to next extensionimport { marked } from "marked";
const toc = [];
marked.use({
walkTokens(token) {
if (token.type === 'heading' && token.depth <= 3) {
const id = token.text
.toLowerCase()
.replace(/[^\w\s-]/g, '')
.replace(/\s+/g, '-');
toc.push({
level: token.depth,
text: token.text,
id: id
});
token.headingId = id;
}
},
renderer: {
heading({ tokens, depth, headingId }) {
const text = this.parser.parseInline(tokens);
const id = headingId || text.toLowerCase().replace(/[^\w]+/g, '-');
return `<h${depth} id="${id}">${text}</h${depth}>\n`;
}
}
});
const html = marked.parse(markdown);
// Generate TOC
const tocHtml = '<nav class="toc" role="navigation" aria-label="Table of Contents">\n' +
' <h2>Contents</h2>\n' +
' <ul>\n' +
toc.map(item => {
const indent = ' '.repeat(item.level - 1);
return `${indent}<li><a href="#${item.id}">${escapeHtml(item.text)}</a></li>`;
}).join('\n') +
'\n </ul>\n</nav>';
// Combine TOC with content
const fullHtml = tocHtml + '\n' + html;
// Clear TOC for next document
toc.length = 0;
function escapeHtml(html) {
return String(html)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}import { marked } from "marked";
import hljs from "highlight.js";
marked.use({
renderer: {
code({ text, lang, escaped }) {
// Validate language before highlighting
if (lang && hljs.getLanguage(lang)) {
try {
const highlighted = hljs.highlight(text, { language: lang }).value;
return `<pre><code class="hljs language-${escapeHtml(lang)}">${highlighted}</code></pre>\n`;
} catch (err) {
// Fall through to default on error
console.error('Highlight error:', err);
}
}
// Default rendering
const code = escaped ? text : escapeHtml(text);
const langClass = lang ? ` class="language-${escapeHtml(lang)}"` : '';
return `<pre><code${langClass}>${code}</code></pre>\n`;
}
}
});
function escapeHtml(html) {
return String(html)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}import { marked } from "marked";
marked.use({
extensions: [{
name: 'alert',
level: 'block',
start(src) {
return src.match(/^:::/)?.index;
},
tokenizer(src) {
// Match :::type\ncontent\n::: syntax
const rule = /^:::(\w+)\n([\s\S]*?)\n:::/;
const match = rule.exec(src);
if (match) {
return {
type: 'alert',
raw: match[0],
alertType: match[1],
text: match[2],
tokens: this.lexer.blockTokens(match[2])
};
}
},
renderer(token) {
const body = this.parser.parse(token.tokens);
const icon = getAlertIcon(token.alertType);
return `<div class="alert alert-${token.alertType}" role="alert">
<div class="alert-icon" aria-hidden="true">${icon}</div>
<div class="alert-content">${body}</div>
</div>\n`;
}
}],
childTokens: ['tokens']
});
function getAlertIcon(type) {
const icons = {
note: 'ℹ️',
warning: '⚠️',
danger: '🚫',
tip: '💡',
success: '✅'
};
return icons[type] || 'ℹ️';
}
// Usage:
// :::warning
// This is a warning message with **markdown**.
// :::import { marked } from "marked";
marked.use({
extensions: [
// Inline math: $x^2$
{
name: 'inlineMath',
level: 'inline',
start(src) {
return src.indexOf('$');
},
tokenizer(src) {
const match = src.match(/^\$([^$\n]+?)\$/);
if (match) {
return {
type: 'inlineMath',
raw: match[0],
text: match[1]
};
}
},
renderer(token) {
return `<span class="math-inline">\\(${escapeHtml(token.text)}\\)</span>`;
}
},
// Block math: $$\n...\n$$
{
name: 'blockMath',
level: 'block',
start(src) {
return src.match(/^\$\$/)?.index;
},
tokenizer(src) {
const match = src.match(/^\$\$\n([\s\S]+?)\n\$\$/);
if (match) {
return {
type: 'blockMath',
raw: match[0],
text: match[1]
};
}
},
renderer(token) {
return `<div class="math-block">\\[${escapeHtml(token.text)}\\]</div>\n`;
}
}
]
});
function escapeHtml(html) {
return String(html)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
// Then include MathJax or KaTeX to render the LaTeXimport { marked } from "marked";
marked.use({
extensions: [{
name: 'wikilink',
level: 'inline',
start(src) {
return src.indexOf('[[');
},
tokenizer(src) {
const match = src.match(/^\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/);
if (match) {
const page = match[1].trim();
const display = match[2] ? match[2].trim() : page;
const slug = page.toLowerCase().replace(/\s+/g, '-');
return {
type: 'wikilink',
raw: match[0],
page: page,
display: display,
slug: slug
};
}
},
renderer(token) {
return `<a href="/wiki/${encodeURIComponent(token.slug)}" class="wikilink" data-page="${escapeHtml(token.page)}">${escapeHtml(token.display)}</a>`;
}
}]
});
function escapeHtml(html) {
return String(html)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
// Usage:
// [[Page Name]] -> links to "page-name" with display "Page Name"
// [[Page Name|Display]] -> links to "page-name" with display "Display"import { marked } from "marked";
const footnotes = {};
let footnoteCounter = 0;
marked.use({
extensions: [
// Footnote reference: [^1]
{
name: 'footnoteRef',
level: 'inline',
start(src) {
return src.indexOf('[^');
},
tokenizer(src) {
const match = src.match(/^\[\^(\w+)\]/);
if (match) {
return {
type: 'footnoteRef',
raw: match[0],
id: match[1]
};
}
},
renderer(token) {
if (!footnotes[token.id]) {
footnotes[token.id] = { num: ++footnoteCounter };
}
const num = footnotes[token.id].num;
return `<sup class="footnote-ref"><a href="#fn-${token.id}" id="fnref-${token.id}" role="doc-noteref">${num}</a></sup>`;
}
},
// Footnote definition: [^1]: Text
{
name: 'footnoteDef',
level: 'block',
start(src) {
return src.match(/^\[\^/)?.index;
},
tokenizer(src) {
const match = src.match(/^\[\^(\w+)\]:\s+(.+?)(?:\n(?!\s)|$)/s);
if (match) {
return {
type: 'footnoteDef',
raw: match[0],
id: match[1],
text: match[2].trim()
};
}
},
renderer(token) {
if (!footnotes[token.id]) {
footnotes[token.id] = { num: ++footnoteCounter };
}
footnotes[token.id].text = token.text;
return ''; // Definitions don't render inline
}
}
],
hooks: {
postprocess(html) {
// Append footnotes section at end
if (Object.keys(footnotes).length === 0) {
return html;
}
let footnotesHtml = '<section class="footnotes" role="doc-endnotes">\n<hr>\n<ol>\n';
// Sort by number
const sortedFootnotes = Object.entries(footnotes)
.filter(([, data]) => data.text)
.sort((a, b) => a[1].num - b[1].num);
for (const [id, data] of sortedFootnotes) {
footnotesHtml += `<li id="fn-${id}" role="doc-endnote">${escapeHtml(data.text)} <a href="#fnref-${id}" role="doc-backlink">↩</a></li>\n`;
}
footnotesHtml += '</ol>\n</section>';
return html + footnotesHtml;
}
}
});
// Reset footnotes before each parse
function resetFootnotes() {
Object.keys(footnotes).forEach(key => delete footnotes[key]);
footnoteCounter = 0;
}
// Usage:
// This is text[^1] with footnotes[^2].
//
// [^1]: First footnote
// [^2]: Second footnote
resetFootnotes();
const html = marked.parse(markdown);
function escapeHtml(html) {
return String(html)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}import { marked } from "marked";
marked.use({
renderer: {
code({ text, lang }) {
try {
// Potentially failing operation
const result = processCode(text, lang);
return result;
} catch (err) {
// Handle error gracefully
console.error('Code processing error:', err);
// Return safe fallback
return `<pre><code class="error">Error processing code block: ${escapeHtml(err.message)}</code></pre>\n`;
// Or fall back to default
// return false;
}
}
},
walkTokens(token) {
try {
// Process token
if (token.type === 'custom') {
validateCustomToken(token);
}
} catch (err) {
// Log but don't throw (would stop parsing)
console.error('Token validation error:', err);
token.validationError = err.message;
}
}
});
function processCode(text, lang) {
// Some code processing that might throw
return `<pre><code>${escapeHtml(text)}</code></pre>\n`;
}
function validateCustomToken(token) {
// Some validation that might throw
}
function escapeHtml(html) {
return String(html)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}import { marked } from "marked";
// Cache expensive operations
const highlightCache = new Map();
marked.use({
extensions: [{
name: 'customCode',
level: 'block',
start(src) {
// Efficient start function
return src.match(/^```/)?.index;
},
tokenizer(src) {
// Efficient regex
const match = src.match(/^```(\w+)\n([\s\S]*?)\n```/);
if (match) {
return {
type: 'customCode',
raw: match[0],
lang: match[1],
text: match[2]
};
}
},
renderer(token) {
// Cache expensive highlighting
const cacheKey = `${token.lang}:${token.text}`;
if (highlightCache.has(cacheKey)) {
return highlightCache.get(cacheKey);
}
const result = expensiveHighlight(token.text, token.lang);
// Implement cache size limit
if (highlightCache.size > 1000) {
const firstKey = highlightCache.keys().next().value;
highlightCache.delete(firstKey);
}
highlightCache.set(cacheKey, result);
return result;
}
}]
});
function expensiveHighlight(text, lang) {
// Simulated expensive operation
return `<pre><code class="language-${lang}">${escapeHtml(text)}</code></pre>\n`;
}
function escapeHtml(html) {
return String(html)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}import { marked } from "marked";
import assert from "assert";
// Test custom extension
marked.use({
extensions: [{
name: 'mention',
level: 'inline',
start(src) {
return src.indexOf('@');
},
tokenizer(src) {
const match = src.match(/^@(\w+)/);
if (match) {
return {
type: 'mention',
raw: match[0],
username: match[1]
};
}
},
renderer(token) {
return `<a href="/users/${escapeURI(token.username)}" class="mention">@${escapeHtml(token.username)}</a>`;
}
}]
});
// Test cases
describe('Mention Extension', () => {
it('should parse single mention', () => {
const html = marked.parse('Hello @john');
assert.strictEqual(
html,
'<p>Hello <a href="/users/john" class="mention">@john</a></p>\n'
);
});
it('should parse multiple mentions', () => {
const html = marked.parse('@jane and @doe');
assert(html.includes('@jane'));
assert(html.includes('@doe'));
});
it('should not match invalid mentions', () => {
const html = marked.parse('Email: test@example.com');
assert(!html.includes('class="mention"'));
});
it('should handle mentions with other markdown', () => {
const html = marked.parse('Hello **@john**');
assert(html.includes('mention'));
assert(html.includes('<strong>'));
});
});
function escapeHtml(html) {
return String(html)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
function escapeURI(str) {
return encodeURIComponent(str);
}// ❌ BAD
tokenizer(src) {
if (!src.match(/pattern/)) {
return null; // WRONG!
}
}
// ✓ GOOD
tokenizer(src) {
if (!src.match(/pattern/)) {
return undefined; // or just return;
}
}// ❌ BAD
tokenizer(src) {
const match = src.match(/^pattern/);
return { type: 'custom' }; // Missing raw!
}
// ✓ GOOD
tokenizer(src) {
const match = src.match(/^pattern/);
return { type: 'custom', raw: match[0] };
}// ❌ SLOW: Tokenizer called for every character
{
name: 'custom',
level: 'inline',
// No start function
tokenizer(src) {
// Called very frequently
}
}
// ✓ FAST: Start function optimizes
{
name: 'custom',
level: 'inline',
start(src) {
return src.indexOf('@'); // Quickly skip to potential tokens
},
tokenizer(src) {
// Only called when '@' is found
}
}// ❌ BAD: Not parsing nested markdown
renderer(token) {
return `<div>${token.text}</div>`; // Renders raw markdown
}
// ✓ GOOD: Parsing nested tokens
renderer(token) {
const content = this.parser.parse(token.tokens);
return `<div>${content}</div>`;
}// ❌ BAD: XSS vulnerability
renderer(token) {
return `<div class="${token.className}">${token.text}</div>`;
}
// ✓ GOOD: Escaping user content
renderer(token) {
return `<div class="${escapeHtml(token.className)}">${escapeHtml(token.text)}</div>`;
}
function escapeHtml(html) {
return String(html)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}undefined from tokenizers when no match (not null or false)raw property in tokensstart() function for performance optimization^ regex anchorthis.lexer.inlineTokens() or this.lexer.blockTokens()this.parser.parse() or this.parser.parseInline()false from renderers to fall back to default