or run

tessl search
Log in

Version

Workspace
tessl
Visibility
Public
Created
Last updated
Describes
npmpkg:npm/marked@17.0.x

docs

index.md
tile.json

tessl/npm-marked

tessl install tessl/npm-marked@17.0.0

A markdown parser built for speed

extensions.mddocs/reference/

Extension System

Marked's extension system allows you to customize parsing and rendering behavior by adding custom tokenizers, renderers, and processing hooks.

Quick Reference

Extension TypePurposeLevelReturns
Tokenizer ExtensionAdd new syntax'block' or 'inline'Token object or undefined
Renderer ExtensionCustom HTML output-HTML string or false
Tokenizer OverrideModify built-in syntax-Token object or false
Renderer OverrideModify built-in output-HTML string or false
HooksIntercept processing-Processed content
walkTokensProcess all tokens-void or Promise<void>

Using Extensions

/**
 * 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.

Extension Application Order

Extensions and options are applied in this order (later overrides earlier):

  1. Built-in defaults
  2. Global setOptions()
  3. Extensions via use() (in order called)
  4. Parse-time options
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 });

Extension Types

Tokenizer Extensions

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:

  • Always return undefined (not null or false) when no match
  • Always include raw property in returned token
  • Use start() function for performance with large documents
  • Match from the beginning of src (use ^ anchor in regex)
  • Consider nested content - use 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
}

Nested Content in Extensions

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*.
// :::

Renderer Extensions

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 instance
  • this.parser.parse(tokens): Render block tokens
  • this.parser.parseInline(tokens): Render inline tokens
  • Return false to fall back to default or next renderer

Renderer 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;
    }
  }
});

Combined Tokenizer and Renderer Extensions

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}

Renderer Overrides

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, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#39;');
}

Renderer Override Notes:

  • Methods receive token objects with all properties
  • Must call this.parser.parseInline() or this.parser.parse() for nested tokens
  • Return false to use default renderer
  • Can access this.options for current configuration

Tokenizer Overrides

Override 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, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#39;');
}

// Usage:
// ```javascript [example.js] {data-line="1-5"}
// code here
// ```
//
// # Heading {#custom-id .special}
//
// [Jump to section](#custom-id)

Tokenizer Override Notes:

  • Return token object if matched, false or undefined if not
  • Access this.lexer for recursive tokenization
  • Access this.options for configuration
  • Tokenizer context provides this.lexer.inline() and this.lexer.blockTokens()

Token Walking

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, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#39;');
}

WalkTokens Best Practices:

  • Modify tokens in place (direct property assignment)
  • Return value is ignored (typically void)
  • For async operations, set async: true and use async/await
  • Called before rendering, so modifications affect output
  • Traverses entire tree including deeply nested tokens

Hook Extensions

Hooks 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++}`;
}

Full Hooks Documentation

Extension Context

Within tokenizer and renderer functions, this provides context:

Tokenizer 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
    };
  }
}

Renderer Context

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

Extension Stacking

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, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#39;');
}

Stacking Rules:

  • Extensions are tried in registration order
  • Returning false passes to next extension
  • Last resort is built-in default renderer
  • First non-false return value is used

Real-World Examples

Add Table of Contents

import { 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, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#39;');
}

Syntax Highlighting

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, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#39;');
}

Custom Alert Blocks

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**.
// :::

Math Expression Support

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, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#39;');
}

// Then include MathJax or KaTeX to render the LaTeX

Wiki-Style Links

import { 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, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#39;');
}

// Usage: 
// [[Page Name]]          -> links to "page-name" with display "Page Name"
// [[Page Name|Display]]  -> links to "page-name" with display "Display"

Footnote Support

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, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#39;');
}

Error Handling in Extensions

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, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#39;');
}

Performance Optimization

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, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#39;');
}

Testing Extensions

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, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#39;');
}

function escapeURI(str) {
  return encodeURIComponent(str);
}

Common Extension Pitfalls

1. Not Returning Undefined

// ❌ BAD
tokenizer(src) {
  if (!src.match(/pattern/)) {
    return null;  // WRONG!
  }
}

// ✓ GOOD
tokenizer(src) {
  if (!src.match(/pattern/)) {
    return undefined;  // or just return;
  }
}

2. Missing Raw Property

// ❌ 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] };
}

3. Not Using Start Function

// ❌ 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
  }
}

4. Not Handling Nested Tokens

// ❌ 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>`;
}

5. Not Escaping HTML

// ❌ 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, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#39;');
}

Extension Best Practices Summary

  1. Always return undefined from tokenizers when no match (not null or false)
  2. Always include raw property in tokens
  3. Use start() function for performance optimization
  4. Match from start of source using ^ regex anchor
  5. Handle nested content using this.lexer.inlineTokens() or this.lexer.blockTokens()
  6. Parse nested tokens in renderers using this.parser.parse() or this.parser.parseInline()
  7. Escape HTML to prevent XSS vulnerabilities
  8. Return false from renderers to fall back to default
  9. Handle errors gracefully - don't break entire parse
  10. Cache expensive operations in renderers
  11. Test thoroughly with edge cases
  12. Document custom properties for maintainability