Skip to the content.

Custom Parsers

This guide explains how to create custom parsers for elements and control their execution order using parser priority.

Table of Contents

Overview

Custom parsers allow you to define non-standard syntax for elements. When content is encountered that doesn’t match the standard Chalk syntax, the compiler tries each element’s custom parser to see if it can process the content.

Key Concepts:

Parser Priority System

When multiple parsers could potentially match the same input, parser priority determines which parser is tried first.

How Priority Works

  1. Parsers are tried in descending priority order (highest priority first)
  2. Default priority is 5 (if not specified)
  3. When priorities are equal, parsers are tried in alphabetical order by element name
  4. First successful match wins - once a parser successfully matches, no other parsers are tried

Why Priority Matters

Without priority control, a generic paragraph parser might match heading content before the heading parser gets a chance to run. Priority ensures more specific parsers are tried before generic catch-all parsers.

Example Problem:

// Without priority, both could match "Hello world"
paragraphParser: matches any text  priority 0
headingParser: matches "# Hello world"  priority 20

// With priority: headingParser tries first, only falls back to paragraph if no match

Creating Custom Parsers

Basic Parser Structure

lib.element({
  id: 'myElement',
  body: 'literal',
  parser: (input) => {
    // Try to match your custom syntax
    const match = input.match(/your-regex-pattern/);
    
    if (!match) {
      // Parsing failed - this parser can't handle this input
      return { ok: false, error: 'Expected custom syntax' };
    }
    
    // Parsing succeeded - return element data
    return {
      ok: true,
      data: {
        instance_id: generateElementId(),
        identifier: 'myElement',
        body: match[1],  // extracted content
        detail: null,
        attributes: []
      }
    };
  },
  parserPriority: 10  // Medium priority
});

Parser Function Signature

parser: (input: string, definition: ElementDefinition) => Result<ElementContent>

Parameters:

Returns:

Priority Guidelines

Priority Range Use Case Examples
20-30 Very specific, unambiguous syntax Headings (h1-h6), code fences
10-19 Moderately specific patterns Links, lists, blockquotes
5-9 Generic patterns with some specificity Bold, italic, inline code
5 (default) Standard elements Most custom elements
0-4 Fallback/catch-all parsers Paragraphs, plain text

Choosing the Right Priority

High Priority (20-30):

Medium Priority (10-19):

Default Priority (5):

Low Priority (0-4):

Built-in Primitive Priorities

The primitive elements included with every library have the following priorities:

// Headings (h1-h6)
parserPriority: 20

// Paragraphs
parserPriority: 0  // Last resort catch-all

When creating custom elements, consider where they fit in relation to these primitives.

Common Patterns

Pattern 1: Specific Syntax (High Priority)

Use for well-defined, unambiguous syntax:

lib.element({
  id: 'callout',
  body: 'literal',
  attributes: [
    { id: 'type', type: 'string', default: 'info' }
  ],
  parserPriority: 20,  // High priority - specific syntax
  parser: (input) => {
    // Matches: !!! Note: Important message
    const match = input.match(/^!!! (.+?):\s*(.+)$/);
    if (!match) return { ok: false, error: 'Expected callout syntax' };
    
    return {
      ok: true,
      data: {
        instance_id: generateElementId(),
        identifier: 'callout',
        body: match[2],
        detail: null,
        attributes: [
          { instance_id: generateAttributeId(), identifier: 'type', value: match[1].toLowerCase() }
        ]
      }
    };
  }
});

Pattern 2: Markdown-Style Syntax (Medium Priority)

Use for patterns similar to markdown:

lib.element({
  id: 'link',
  body: 'literal',
  attributes: [
    { id: 'url', type: 'string', required: true },
    { id: 'target', type: 'string' }
  ],
  parserPriority: 15,  // Medium priority
  parser: (input) => {
    // Matches: [text](url)
    const match = input.match(/^\[(.+?)\]\((.+?)\)$/);
    if (!match) return { ok: false, error: 'Expected [text](url)' };
    
    return {
      ok: true,
      data: {
        instance_id: generateElementId(),
        identifier: 'link',
        body: match[1],  // Link text
        detail: null,
        attributes: [
          { instance_id: generateAttributeId(), identifier: 'url', value: match[2] }
        ]
      }
    };
  }
});

Pattern 3: Multiple Matches (Default Priority)

When your parser can match multiple instances:

lib.element({
  id: 'item',
  body: 'literal',
  parserPriority: 5,  // Default priority
  parser: (input) => {
    // Matches multiple items, one per line
    const matches = input.match(/^- (.+)$/gm);
    if (!matches) return { ok: true, data: [] };  // No matches is ok
    
    return {
      ok: true,
      data: matches.map(match => ({
        instance_id: generateElementId(),
        identifier: 'item',
        body: match.replace(/^- /, ''),
        detail: null,
        attributes: []
      }))
    };
  }
});

Pattern 4: Catch-All Parser (Low Priority)

Use only for last-resort parsers:

lib.element({
  id: 'text',
  body: 'literal',
  parserPriority: 0,  // Lowest priority - last resort
  parser: (input) => {
    // Matches any non-empty text
    const lines = input.split('\n').filter(line => line.trim());
    if (lines.length === 0) return { ok: true, data: [] };
    
    return {
      ok: true,
      data: lines.map(line => ({
        instance_id: generateElementId(),
        identifier: 'text',
        body: line,
        detail: null,
        attributes: []
      }))
    };
  }
});

Examples

Example 1: Custom Heading Syntax

lib.element({
  id: 'heading',
  body: 'literal',
  attributes: [
    { id: 'level', type: 'number', default: 1 }
  ],
  parserPriority: 20,  // High priority
  parser: (input) => {
    // Matches: === Heading ===
    const match = input.match(/^(=+)\s+(.+?)\s+\1$/);
    if (!match) return { ok: false, error: 'Expected === heading ===' };
    
    const level = Math.min(match[1].length, 6);
    
    return {
      ok: true,
      data: {
        instance_id: generateElementId(),
        identifier: 'heading',
        body: match[2],
        detail: null,
        attributes: [
          { instance_id: generateAttributeId(), identifier: 'level', value: level }
        ]
      }
    };
  }
});

Example 2: Admonition Blocks

lib.element({
  id: 'admonition',
  body: 'literal',
  attributes: [
    { id: 'type', type: 'string', default: 'note' },
    { id: 'title', type: 'string' }
  ],
  parserPriority: 18,
  parser: (input) => {
    // Matches:
    // ::: warning Important
    // Content here
    // :::
    const match = input.match(/^:::[ ]*(\w+)(?:[ ]+(.+?))?\n([\s\S]+?)\n:::$/);
    if (!match) return { ok: false, error: 'Expected ::: type title\\ncontent\\n:::' };
    
    const attributes = [
      { instance_id: generateAttributeId(), identifier: 'type', value: match[1] }
    ];
    
    if (match[2]) {
      attributes.push(
        { instance_id: generateAttributeId(), identifier: 'title', value: match[2] }
      );
    }
    
    return {
      ok: true,
      data: {
        instance_id: generateElementId(),
        identifier: 'admonition',
        body: match[3].trim(),
        detail: null,
        attributes
      }
    };
  }
});

Example 3: Inline Elements

lib.element({
  id: 'emoji',
  body: 'literal',
  attributes: [
    { id: 'name', type: 'string', required: true }
  ],
  parserPriority: 12,
  parser: (input) => {
    // Matches: :smile: :heart: :rocket:
    const matches = input.match(/:(\w+):/g);
    if (!matches) return { ok: true, data: [] };
    
    return {
      ok: true,
      data: matches.map(match => {
        const name = match.slice(1, -1);
        return {
          instance_id: generateElementId(),
          identifier: 'emoji',
          body: match,
          detail: null,
          attributes: [
            { instance_id: generateAttributeId(), identifier: 'name', value: name }
          ]
        };
      })
    };
  }
});

Example 4: Priority Ordering in Practice

import chalk from '@jackhgns/dotchalk';

const lib = chalk.library()
  .document({ name: 'doc', body: 'all' })
  
  // Priority 20: Try first - very specific
  .element({
    id: 'h1',
    body: 'literal',
    parserPriority: 20,
    parser: (input) => {
      const match = input.match(/^# (.+)$/gm);
      // ... heading logic
    }
  })
  
  // Priority 15: Try second - moderately specific
  .element({
    id: 'link',
    body: 'literal',
    parserPriority: 15,
    parser: (input) => {
      const match = input.match(/^\[(.+?)\]\((.+?)\)$/);
      // ... link logic
    }
  })
  
  // Priority 5 (default): Try third - standard element
  .element({
    id: 'custom',
    body: 'literal',
    // parserPriority defaults to 5
    parser: (input) => {
      // ... custom logic
    }
  })
  
  // Priority 0: Try last - catch-all
  .element({
    id: 'paragraph',
    body: 'literal',
    parserPriority: 0,
    parser: (input) => {
      const matches = input.match(/^(?!# ).+$/gm);
      // ... paragraph logic (matches anything not a heading)
    }
  });

// When parsing "# Hello", parsers are tried in order:
// 1. h1 (priority 20) → matches! ✓
// 2. link, custom, paragraph not tried

// When parsing "Regular text", parsers are tried in order:
// 1. h1 (priority 20) → no match
// 2. link (priority 15) → no match
// 3. custom (priority 5) → no match
// 4. paragraph (priority 0) → matches! ✓

Best Practices

  1. Be Specific: Use high priority only for truly unambiguous syntax
  2. Test Conflicts: If two parsers could match the same input, the higher priority wins
  3. Start with Default: Use priority 5 (default) unless you have a specific reason to change it
  4. Document Your Choices: Comment why you chose a particular priority
  5. Return Empty Arrays: For multi-match parsers, return { ok: true, data: [] } if no matches found
  6. Fail Fast: Return { ok: false, error: '...' } if input definitely doesn’t match your syntax