tessl install tessl/npm-marked@17.0.0A markdown parser built for speed
Hooks provide interception points in the markdown processing pipeline, allowing you to transform content before parsing, after rendering, and at other key stages.
class Hooks {
constructor(options?: MarkedOptions);
/**
* Hook options
*/
options: MarkedOptions;
/**
* Whether processing block-level (true) or inline (false) content
* Set automatically by parser
*/
block?: boolean;
/**
* Set of hook names that pass their input to the next hook
* Includes: preprocess, postprocess, processAllTokens, emStrongMask
*/
static readonly passThroughHooks: Set<string>;
/**
* Set of pass-through hooks that respect async mode
* Includes: preprocess, postprocess, processAllTokens
*/
static readonly passThroughHooksRespectAsync: Set<string>;
/**
* Process markdown before marked parses it
* Use cases: template substitution, custom syntax preprocessing, content normalization
* @param markdown - Raw markdown string
* @returns Processed markdown string (or Promise when async)
*/
preprocess(markdown: string): string | Promise<string>;
/**
* Process HTML after marked generates it
* Use cases: wrapper elements, class additions, post-processing transformations
* @param html - Generated HTML string
* @returns Processed HTML string (or Promise when async)
*/
postprocess(html: string): string | Promise<string>;
/**
* Process all tokens after lexing but before walkTokens
* Use cases: bulk modifications, validation, metadata extraction, token filtering
* @param tokens - Token array or TokensList
* @returns Processed token array (or Promise when async)
*/
processAllTokens(tokens: Token[] | TokensList): Token[] | TokensList | Promise<Token[] | TokensList>;
/**
* Mask content that should not be interpreted as em/strong delimiters
* Used to prevent emphasis parsing in certain contexts
* Use cases: protecting custom syntax, preserving special character sequences
* @param src - Source text to mask
* @returns Masked source text (same length, with em/strong delimiters replaced)
*/
emStrongMask(src: string): string;
/**
* Provide a custom function to tokenize markdown
* Advanced use case: completely replace lexer
* @returns Lexer function (either lex or lexInline based on context)
*/
provideLexer(): typeof Lexer.lex | typeof Lexer.lexInline;
/**
* Provide a custom function to parse tokens
* Advanced use case: completely replace parser
* @returns Parser function (either parse or parseInline based on context)
*/
provideParser(): typeof Parser.parse | typeof Parser.parseInline;
}Pass-through hooks receive input, process it, and pass the result to the next hook in the chain:
preprocess - Processes markdown before parsingpostprocess - Processes HTML after renderingprocessAllTokens - Processes tokens before walkTokensemStrongMask - Masks regions that shouldn't be parsed as emphasisChaining Behavior: When multiple extensions define the same pass-through hook, they execute in order, with each receiving the output of the previous.
Provider hooks return functions that replace default behavior:
provideLexer - Returns custom lexer functionprovideParser - Returns custom parser functionOverride Behavior: Only the last provider hook is used (last extension wins).
Modify markdown before it is parsed:
import { marked } from "marked";
marked.use({
hooks: {
preprocess(markdown) {
// Replace custom shortcodes
return markdown
.replace(/\{DATE\}/g, new Date().toLocaleDateString())
.replace(/\{VERSION\}/g, '1.0.0')
.replace(/\{YEAR\}/g, new Date().getFullYear().toString());
}
}
});
const html = marked.parse('Version: {VERSION}, Date: {DATE}');
// Expands shortcodes before parsingUse Cases:
Edge Cases:
Modify HTML after it is generated:
import { marked } from "marked";
marked.use({
hooks: {
postprocess(html) {
// Add responsive classes to tables
return html
.replace(/<table>/g, '<table class="table table-responsive">')
.replace(/<img /g, '<img loading="lazy" ');
}
}
});
const html = marked.parse('| A | B |\n|---|---|\n| 1 | 2 |');
// Tables have custom class, images have lazy loadingUse Cases:
Edge Cases:
Modify tokens after lexing but before walkTokens:
import { marked } from "marked";
marked.use({
hooks: {
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);
}
});
}
addMetadata(tokens);
return tokens;
}
}
});Use Cases:
Important:
walkTokens extensionPrevent emphasis parsing in specific contexts:
import { marked } from "marked";
marked.use({
hooks: {
emStrongMask(src) {
// Mask content inside [[ ]] so it's not parsed as emphasis
// Replace * and _ with spaces (same length preservation)
return src.replace(/\[\[([^\]]+)\]\]/g, (match, content) => {
// Mask * and _ characters to prevent em/strong parsing
const masked = content.replace(/[*_]/g, ' ');
return '[[' + masked + ']]';
});
}
}
});
// Now [[text_with_underscores]] won't have underscores treated as emphasisImportant:
* and _)Replace the lexer with a custom implementation:
import { marked, Lexer } from "marked";
marked.use({
hooks: {
provideLexer() {
// Return custom lexer function
return (src, options) => {
console.log('Custom lexer called with', src.length, 'characters');
// Use default lexer or implement custom
// this.block indicates if processing block or inline
return this.block ? Lexer.lex(src, options) : Lexer.lexInline(src, options);
};
}
}
});Use Cases:
Warning: Advanced use case. Incorrect implementation will break parsing.
Replace the parser with a custom implementation:
import { marked, Parser } from "marked";
marked.use({
hooks: {
provideParser() {
// Return custom parser function
return (tokens, options) => {
console.log('Custom parser called with', tokens.length, 'tokens');
// Use default parser or implement custom
return this.block ? Parser.parse(tokens, options) : Parser.parseInline(tokens, options);
};
}
}
});Use Cases:
Warning: Advanced use case. Incorrect implementation will break rendering.
Hooks support async operations when async: true is set:
import { marked } from "marked";
import fetch from "node-fetch";
marked.use({
async: true,
hooks: {
async preprocess(markdown) {
// Async preprocessing (e.g., fetch external content)
const includeRegex = /@include\((.*?)\)/g;
let result = markdown;
const matches = [...markdown.matchAll(includeRegex)];
for (const match of matches) {
const url = match[1];
try {
const response = await fetch(url);
const content = await response.text();
result = result.replace(match[0], content);
} catch (err) {
console.error(`Failed to include ${url}:`, err);
result = result.replace(match[0], `<!-- Error including ${url} -->`);
}
}
return result;
},
async postprocess(html) {
// Async postprocessing
const processed = await processWithExternalService(html);
return processed;
}
}
});
// Must await when async is true
const html = await marked.parse('Content: @include(https://example.com/snippet.md)', { async: true });Async Notes:
async: true in extension or optionsMultiple hooks can be chained together:
import { marked } from "marked";
// First extension
marked.use({
hooks: {
preprocess(markdown) {
console.log('First preprocess');
return markdown.replace(/foo/g, 'bar');
},
postprocess(html) {
console.log('First postprocess');
return html.replace(/<p>/g, '<p class="first">');
}
}
});
// Second extension
marked.use({
hooks: {
preprocess(markdown) {
console.log('Second preprocess');
return markdown.replace(/hello/g, 'world');
},
postprocess(html) {
console.log('Second postprocess');
return html.replace(/<\/p>/g, '</p><!-- end -->');
}
}
});
// Execution order:
// 1. First preprocess
// 2. Second preprocess
// 3. Parse
// 4. First postprocess
// 5. Second postprocessChaining Rules:
import { marked } from "marked";
import yaml from "js-yaml";
let frontMatter = {};
marked.use({
hooks: {
preprocess(markdown) {
// Extract YAML front matter
const match = markdown.match(/^---\n([\s\S]+?)\n---\n/);
if (match) {
try {
frontMatter = yaml.load(match[1]);
} catch (err) {
console.error('Invalid front matter:', err);
frontMatter = {};
}
// Remove front matter from markdown
return markdown.slice(match[0].length);
}
frontMatter = {};
return markdown;
},
postprocess(html) {
// Add front matter as meta tags
const metaTags = Object.entries(frontMatter)
.map(([key, value]) => `<meta name="${key}" content="${escapeHtml(value)}">`)
.join('\n');
return metaTags ? metaTags + '\n' + html : html;
}
}
});
function escapeHtml(text) {
return String(text)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"');
}import { marked } from "marked";
import fs from "fs";
import path from "path";
marked.use({
hooks: {
preprocess(markdown) {
// Replace @include directives
return markdown.replace(/@include\(([^)]+)\)/g, (match, filename) => {
try {
const fullPath = path.resolve(filename);
return fs.readFileSync(fullPath, 'utf-8');
} catch (err) {
console.error(`Error including ${filename}:`, err);
return `<!-- Error: Could not include ${filename} -->`;
}
});
}
}
});
// Usage: @include(./snippet.md)import { marked } from "marked";
const toc = [];
marked.use({
hooks: {
processAllTokens(tokens) {
// Extract headings for TOC
toc.length = 0; // Clear previous TOC
function extractHeadings(tokens) {
tokens.forEach(token => {
if (token.type === 'heading' && token.depth <= 3) {
toc.push({
level: token.depth,
text: token.text,
id: token.text.toLowerCase().replace(/[^\w]+/g, '-')
});
}
if (token.tokens) {
extractHeadings(token.tokens);
}
});
}
extractHeadings(tokens);
return tokens;
},
postprocess(html) {
// Prepend TOC to HTML
if (toc.length === 0) {
return html;
}
const tocHtml = '<nav class="toc">\n' +
toc.map(item =>
` <a href="#${item.id}" class="toc-level-${item.level}">${item.text}</a>`
).join('\n') +
'\n</nav>\n\n';
return tocHtml + html;
}
}
});import { marked } from "marked";
const variables = {
author: 'John Doe',
date: '2024-01-01',
version: '1.0.0'
};
marked.use({
hooks: {
preprocess(markdown) {
// Replace {{variable}} with values
return markdown.replace(/\{\{(\w+)\}\}/g, (match, key) => {
if (key in variables) {
return variables[key];
}
console.warn(`Unknown variable: ${key}`);
return match; // Leave unchanged if not found
});
}
}
});
const html = marked.parse('Author: {{author}}, Version: {{version}}');import { marked } from "marked";
const errors = [];
marked.use({
hooks: {
processAllTokens(tokens) {
errors.length = 0; // Clear previous errors
function validate(tokens) {
tokens.forEach(token => {
// Validate links have href
if (token.type === 'link' && !token.href) {
errors.push(`Invalid link at: ${token.raw}`);
}
// Validate images have alt text
if (token.type === 'image' && !token.text) {
errors.push(`Image missing alt text: ${token.href}`);
}
// Validate code blocks have language
if (token.type === 'code' && token.codeBlockStyle !== 'indented' && !token.lang) {
errors.push(`Code block missing language: ${token.raw.substring(0, 50)}`);
}
if (token.tokens) {
validate(token.tokens);
}
});
}
validate(tokens);
if (errors.length > 0) {
console.warn('Markdown validation errors:', errors);
}
return tokens;
}
}
});import { marked } from "marked";
marked.use({
hooks: {
processAllTokens(tokens) {
// Remove or sanitize dangerous tokens
function sanitize(tokens) {
return tokens.filter(token => {
// Remove raw HTML tokens
if (token.type === 'html') {
console.warn('Removed HTML token:', token.raw);
return false;
}
// Sanitize link hrefs
if (token.type === 'link') {
const url = token.href.toLowerCase();
if (url.startsWith('javascript:') || url.startsWith('data:')) {
console.warn('Neutralized dangerous link:', token.href);
token.href = '#'; // Neutralize dangerous links
}
}
// Recursively sanitize nested tokens
if (token.tokens) {
token.tokens = sanitize(token.tokens);
}
return true;
});
}
// Modify tokens in place
const sanitized = sanitize(tokens);
tokens.length = 0;
tokens.push(...sanitized);
return tokens;
}
}
});import { marked } from "marked";
let stats = {};
marked.use({
hooks: {
processAllTokens(tokens) {
stats = {
headings: 0,
paragraphs: 0,
codeBlocks: 0,
links: 0,
images: 0,
lists: 0,
tables: 0
};
function countTokens(tokens) {
tokens.forEach(token => {
switch (token.type) {
case 'heading': stats.headings++; break;
case 'paragraph': stats.paragraphs++; break;
case 'code': stats.codeBlocks++; break;
case 'link': stats.links++; break;
case 'image': stats.images++; break;
case 'list': stats.lists++; break;
case 'table': stats.tables++; break;
}
if (token.tokens) {
countTokens(token.tokens);
}
});
}
countTokens(tokens);
return tokens;
}
}
});
const html = marked.parse(markdown);
console.log('Document statistics:', stats);import { marked } from "marked";
let readingTime = 0;
const WORDS_PER_MINUTE = 200;
marked.use({
hooks: {
processAllTokens(tokens) {
let wordCount = 0;
function countWords(tokens) {
tokens.forEach(token => {
if (token.type === 'text' && token.text) {
wordCount += token.text.split(/\s+/).length;
}
if (token.type === 'code' && token.text) {
// Count code words at half speed
wordCount += token.text.split(/\s+/).length * 0.5;
}
if (token.tokens) {
countWords(token.tokens);
}
});
}
countWords(tokens);
readingTime = Math.ceil(wordCount / WORDS_PER_MINUTE);
return tokens;
},
postprocess(html) {
return `<div class="reading-time">${readingTime} min read</div>\n${html}`;
}
}
});Hooks have access to this.options and this.block:
import { marked } from "marked";
marked.use({
hooks: {
preprocess(markdown) {
// Access options
const isGfm = this.options.gfm;
const isAsync = this.options.async;
// Access context
const isBlockLevel = this.block;
console.log(`Processing: GFM=${isGfm}, Async=${isAsync}, Block=${isBlockLevel}`);
if (isGfm) {
// GFM-specific preprocessing
return markdown.replace(/~~(\w+)~~/g, '<del>$1</del>');
}
return markdown;
}
}
});Available Context:
this.options: Current marked optionsthis.block: Boolean indicating block vs inline contextimport { marked } from "marked";
const cache = new Map();
marked.use({
hooks: {
preprocess(markdown) {
// Cache expensive preprocessing
if (cache.has(markdown)) {
return cache.get(markdown);
}
const result = expensiveTransform(markdown);
// Implement cache size limit
if (cache.size > 1000) {
const firstKey = cache.keys().next().value;
cache.delete(firstKey);
}
cache.set(markdown, result);
return result;
}
}
});
function expensiveTransform(markdown) {
// Simulate expensive operation
return markdown.replace(/complex-pattern/g, 'replacement');
}import { marked } from "marked";
marked.use({
hooks: {
preprocess(markdown) {
console.log('Preprocess input length:', markdown.length);
console.log('First 100 chars:', markdown.substring(0, 100));
const result = processMarkdown(markdown);
console.log('Preprocess output length:', result.length);
return result;
},
postprocess(html) {
console.log('Postprocess input length:', html.length);
console.log('First 100 chars:', html.substring(0, 100));
const result = processHtml(html);
console.log('Postprocess output length:', result.length);
return result;
},
processAllTokens(tokens) {
console.log('Token count:', tokens.length);
console.log('Token types:', tokens.map(t => t.type));
return tokens;
}
}
});
function processMarkdown(md) { return md; }
function processHtml(html) { return html; }import { marked } from "marked";
marked.use({
hooks: {
preprocess(markdown) {
try {
return dangerousTransform(markdown);
} catch (err) {
console.error('Preprocess error:', err);
// Return original on error
return markdown;
}
},
postprocess(html) {
try {
return transformHtml(html);
} catch (err) {
console.error('Postprocess error:', err);
// Return original on error
return html;
}
},
processAllTokens(tokens) {
try {
return transformTokens(tokens);
} catch (err) {
console.error('Token processing error:', err);
// Return original on error
return tokens;
}
}
}
});
function dangerousTransform(md) { return md; }
function transformHtml(html) { return html; }
function transformTokens(tokens) { return tokens; }