peisar 0.1.2

A practical Markdown parser written in Rust
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
<!-- markdownlint-disable MD033 -->
<!-- markdownlint-disable MD041 -->
<div align="center">
<img src="https://pub-c9ba018358dd48a99b70013b65a25e5f.r2.dev/logo/peisar.webp" width="160" height="160" alt="peisar" />
  <h1>Peisar</h1>
  <p>A Markdown parser and HTML renderer for Node.js and Rust, written in Rust.</p>
</div>

---

[![NPM](https://nodei.co/npm/peisar.svg)](https://nodei.co/npm/peisar/)

---


Peisar parses CommonMark Markdown with GitHub Flavored Markdown (GFM),
Kramdown-style block attributes, YAML front matter, source spans, AST
visitors, custom parser hooks, and configurable HTML output. It ships as
a native Node.js addon **and** as a plain Rust library (`rlib`) for use
in any Rust project.

## Features

- **CommonMark** — headings, paragraphs, code blocks, block quotes, lists,
  thematic breaks, HTML blocks, emphasis, links, images, inline code, and
  hard or soft breaks.
- **GFM** — tables with column alignment, strikethrough, task lists, and
  autolinks.
- **Kramdown attributes** — `{:#id .class key="value"}` on block elements.
- **YAML front matter** — metadata is available separately from the parsed
  Markdown.
- **AST visitors** — transform the parsed AST from JavaScript or Rust
  before reading it or rendering HTML.
- **Custom parser hooks** — parse extension syntax (directives, wikilinks,
  mentions) during parsing, before the built-in matchers.
- **Source spans** — every node carries a zero-based `[start, end)` span
  (line, column, byte offset) into the original source.
- **Node.js bindings** — native N-API addon with TypeScript declarations.
- **Rust crate** — the same parser is usable as a plain Rust library,
  without Node.

## Quick start

Install the package:

```sh
npm install peisar
```

Parse Markdown and render it as HTML:

```js
const { Peisar } = require('peisar')

const document = new Peisar('# Hello **world**')

console.log(document.html)
console.log(document.ast)
console.log(document.astJson)
```

By default, GFM and Kramdown extensions are enabled and `html` is a complete
HTML document.

## Development build

```sh
npm install
npm run build:local
npm test
```

## Configuration

Pass only the options you need; omitted properties retain their defaults.

```js
const { Peisar } = require('peisar')

const document = new Peisar('# Title', {
  gfm: false,
  kramdown: false,
  fragment: true,
})

console.log(document.html) // <h1>Title</h1>
```

Supported options:

- `gfm` and `kramdown` enable their respective syntax extensions (default:
  `true`).
- `fileName` attaches a source name to the returned AST.
- `fragment` returns only rendered body content when `true` (default:
  `false`).
- `charset`, `viewport`, `title`, `bodyClass`, and `style` configure the full
  HTML document. `bodyClass` is applied to `<body>`.

## AST visitors

```js
const { Peisar } = require('peisar')

const document = new Peisar('# Hello', { fragment: true })

document.useVisitor({
  visitBlock([block]) {
    if (block.type === 'Heading') {
      return {
        replaceWith: [{ ...block, attrs: { ...block.attrs, classes: ['title'] } }],
      }
    }
    return { recurse: true }
  },
})

console.log(document.html) // <h1 class="title">Hello</h1>
```

## Custom parser hooks

Visitors transform the AST *after* parsing; parser hooks let extensions
parse custom syntax *during* parsing. Hooks run **before** the built-in
parsers (so they can both introduce new syntax and override built-ins), in
registration order — the first hook to claim a position wins.

`useParser` takes an object with two optional callbacks:

```js
const document = new Peisar(':::note\nmy note\n:::\n\nSee [[Some Page]].', {
  fragment: true,
})

// Positions must be complete objects — the hook's own `pos` value is a
// placeholder; accurate spans are computed automatically.
const zero = { line: 0, column: 0, offset: 0 }

document.useParser({
  // Block hook: receives { line, lineIndex, lines }
  parseBlock([{ line, lines }]) {
    if (!line.startsWith(':::')) return // decline — let other parsers try
    const name = line.slice(3).trim()
    const closeIndex = lines.findIndex((l, i) => i > 0 && l.trim() === ':::')
    if (closeIndex === -1) return
    return {
      block: {
        type: 'HtmlBlock',
        html: `<div class="note" data-name="${name}"></div>`,
        pos: { start: zero, end: zero },
      },
      consumed: closeIndex + 1, // lines consumed (at least 1)
    }
  },

  // Inline hook: receives { rest, index }
  parseInline([{ rest }]) {
    if (!rest.startsWith('[[')) return // decline
    const close = rest.indexOf(']]')
    if (close === -1) return
    const target = rest.slice(2, close)
    return {
      inline: {
        type: 'Link',
        text: [{ type: 'Text', value: target, pos: { start: zero, end: zero } }],
        url: `https://wiki.example.com/${target.replace(/ /g, '_')}`,
        autolink: false,
        pos: { start: zero, end: zero },
      },
      consumed: close + 4, // characters consumed
    }
  },
})

console.log(document.html)
// <div class="note" data-name="note"></div>
// <p>See <a href="https://wiki.example.com/Some_Page">Some Page</a>.</p>
```

Notes:

- Hooks apply everywhere Markdown is parsed — headings, emphasis/link
  children, block quotes, list items, and table cells included.
- `parseBlock` must consume **at least one line**; `parseInline` should
  report `consumed: 0` (or return `undefined`) to decline.
- Source spans of hook-returned nodes are computed from `consumed`; a
  Kramdown `{:...}` attribute line after a hook-parsed block is applied
  automatically.
- Because the document is parsed at construction, `useParser` re-parses the
  original Markdown. Register hooks before reading `ast`, `html`,
  `frontmatter`, or `astJson`.

## YAML front matter

Leading YAML front matter is excluded from the rendered Markdown and exposed
through `frontmatter`:

```js
const document = new Peisar(`---
title: Hello
---

# Hello`)

console.log(document.frontmatter) // { title: 'Hello' }
```

## Rust API

The same parser is available as a plain Rust library — no Node.js runtime
required. The crate builds both an `rlib` (for Rust consumers) and a
`cdylib` (the Node addon), so adding it as a dependency gives you the full
parsing API.

```toml
[dependencies]
peisar = "0.1.2"
```


### Parse Markdown into an AST

`Document::parse` is the entry point. `AstOptions` controls the enabled
extensions, and every node carries a zero-based source `Span`:

```rust
use peisar::token::Block;
use peisar::{AstOptions, Document};

let doc = Document::parse("# Hello **world**\n", &AstOptions::default(), None);

assert_eq!(doc.node_type, "root");
match &doc.children[0] {
    Block::Heading { level, pos, .. } => {
        assert_eq!(*level, 1);
        assert_eq!((pos.start.line, pos.start.column), (0, 0));
    }
    _ => unreachable!(),
}
```

Strict CommonMark (disable GFM and Kramdown) with a file name attached:

```rust
use peisar::{AstOptions, Document};

let opts = AstOptions {
    gfm: false,
    kramdown: false,
    file_name: Some("doc.md".into()),
};

let doc = Document::parse("# Title\n", &opts, opts.file_name.clone());
assert_eq!(doc.file_name.as_deref(), Some("doc.md"));
```

### Parse and render HTML

`Peisar` mirrors the JavaScript class: parse once, then read `html`,
`ast`, `frontmatter`, and `ast_json`:

```rust
use peisar::{Peisar, PeisarOptions};

let mut document = Peisar::new("# Hello **world**".into(), None);

// Default options: GFM + Kramdown enabled, full HTML document.
println!("{}", document.html());
// <!DOCTYPE html>
// <html>
// <head>…</head>
// <body>
// <h1>Hello <strong>world</strong></h1>
// </body>
// </html>

// `fragment: true` renders just the body content.
let mut fragment = Peisar::new(
    "# Hello **world**".into(),
    Some(PeisarOptions {
        fragment: Some(true),
        ..Default::default()
    }),
);
println!("{}", fragment.html()); // <h1>Hello <strong>world</strong></h1>
```

`PeisarOptions` is the same shape as the JavaScript options object —
`gfm`, `kramdown`, `fileName`, `fragment`, `charset`, `viewport`, `title`,
`bodyClass`, and `style` — with every field optional.

### AST, front matter, and JSON

`PeisarAst` is the pure-Rust document holder used by `Peisar`. It parses
front matter eagerly and re-runs registered visitors before each read:

```rust
use peisar::token::Block;
use peisar::PeisarAst;

let mut ast = PeisarAst::new("---\ntitle: Hello\n---\n\n# Hello".into(), None);

// YAML front matter as a serde_json value.
assert_eq!(
    ast.get_frontmatter().unwrap()["title"].as_str(),
    Some("Hello")
);

// The parsed AST (front matter already stripped).
assert!(matches!(&ast.get_ast().children[0], Block::Heading { level: 1, .. }));

// Serialized AST JSON.
let json = ast.ast_json();
assert!(json.contains("\"type\":\"heading\""));
```

Front matter is also available standalone through the `frontmatter` module:

```rust
use peisar::frontmatter::parse_markdown_frontmatter;

let parsed = parse_markdown_frontmatter("---\ntitle: Hello\n---\n\n# Hello")?;

let md: &str = parsed.pure_markdown_content(); // "# Hello"
let yaml = parsed.yaml_data().unwrap();        // {"title": "Hello"}

// Or take both by value in one call.
let (md, yaml) = parsed.into_parts();
```

### AST visitors

Implement the `AstVisitor` trait and run it with `visit_document_mut` to
transform the AST before rendering. Each callback receives a `&mut` node and
returns a control struct that can keep, replace, remove, or insert around
it:

```rust
use peisar::token::{Block, Inline};
use peisar::visitor::{InlineVisitControl, VisitControl, visit_document_mut};
use peisar::{AstOptions, AstVisitor, Document};

struct AddTitleClass;

impl AstVisitor for AddTitleClass {
    fn visit_block(&mut self, block: &mut Block) -> VisitControl {
        if let Block::Heading { attrs: slot, .. } = block {
            let mut attrs = slot.take().unwrap_or_default();
            let mut classes = attrs.classes.take().unwrap_or_default();
            classes.push("title".into());
            attrs.classes = Some(classes);
            *slot = Some(attrs);
            VisitControl::keep_and_recurse() // mutate in place, then recurse
        } else {
            VisitControl::default() // leave everything else untouched
        }
    }

    fn visit_inline(&mut self, _inline: &mut Inline) -> InlineVisitControl {
        InlineVisitControl::default()
    }
}

let mut doc = Document::parse("# Hello\n", &AstOptions::default(), None);
visit_document_mut(&mut doc, &mut AddTitleClass);
```

`VisitControl` / `InlineVisitControl` support `insert_before`,
`insert_after`, `replace_with`, `remove`, and `recurse` (plus
`keep_and_recurse()` / `remove()` / `replace_with(nodes)` convenience
constructors). Replacements are not re-visited.

### Inline parsing

`parse_inline` parses inline content in isolation (for table cells,
headings, or plain text). `parse_inline_with_refs` additionally resolves
reference-style links against a `LinkRefMap`:

```rust
use peisar::token::Inline;
use peisar::{LinkRefMap, parse_inline, parse_inline_with_refs};

let inlines = parse_inline("*hi* and `code`", None);
assert!(matches!(&inlines[0], Inline::Emphasis { .. }));

// Resolve `[label][]` against collected link reference definitions.
let mut refs = LinkRefMap::new();
refs.insert("example".into(), ("https://example.com".into(), None));

let inlines = parse_inline_with_refs("see [example][]", None, Some(&refs));
assert!(matches!(
    &inlines[1],
    Inline::LinkReference { label, url, .. }
        if label == "example" && url == "https://example.com"
));
```

### Custom parser hooks (Rust)

The `AstParser` trait is the Rust counterpart of the JavaScript `useParser`
hooks — hooks run before the built-in matchers, so they can introduce new
syntax or override built-ins:

```rust
use peisar::token::Block;
use peisar::{AstParser, BlockParseContext};

struct Directive;

impl AstParser for Directive {
    fn try_parse_block(&self, ctx: &BlockParseContext) -> Option<(Block, usize)> {
        let name = ctx.line.trim().strip_prefix(":::")?;
        if name.is_empty() {
            return None; // decline — let other parsers try
        }
        let close = ctx.lines.iter().skip(1).position(|l| l.trim() == ":::")?;
        Some((
            Block::HtmlBlock {
                html: format!("<div class=\"directive\" data-name=\"{name}\">"),
                pos: Default::default(), // real span computed automatically
                attrs: None,
            },
            close + 2, // number of source lines consumed
        ))
    }
}
```

`ParseHooks::empty().with(&hook)` builds the ordered hook registry (the
first hook to claim a position wins). Note that hook-armed document parsing
is currently wired through the JS-facing registration APIs —
`PeisarAst::add_parser` / `Peisar::use_parser` — which wrap JS callbacks in
this same trait; pure-Rust hook registration for `Document::parse` is not
yet exposed at the crate root.

### Rust API surface

Everything above is re-exported from the crate root:

| Item | Kind | Purpose |
| --- | --- | --- |
| `Peisar` | struct | Parse + render in one place (mirrors the JS class) |
| `PeisarAst` | struct | Own the AST, visitors, hooks, front matter |
| `PeisarOptions` | struct | Combined parse + render options (JS shape) |
| `Document` | struct | AST root; `Document::parse` entry point |
| `AstOptions` | struct | `gfm`, `kramdown`, `file_name` |
| `parse_inline` / `parse_inline_with_refs` | fn | Standalone inline parsing |
| `LinkRefMap` | type | `label → (url, title)` map for reference links |
| `AstVisitor` | trait | Post-parse AST transforms |
| `AstParser` | trait | During-parse extension hooks |
| `BlockParseContext` / `InlineParseContext` | struct | Data given to hooks |
| `ParseHooks` | struct | Ordered hook registry |
| `token` | module | `Block`, `Inline`, `Table`, `ListItem`, `Attributes`, … |
| `span` | module | `Span`, `Position` source mapping |
| `visitor` | module | `VisitControl`, `InlineVisitControl`, `visit_document_mut` |
| `frontmatter` | module | `parse_markdown_frontmatter`, `ParseResult` |

Run the API docs locally with:

```sh
cargo doc --no-deps --open
```

## GFM examples

### Tables

```markdown
| Left | Center | Right |
| :--- | :----: | ----: |
| a    |   b    |     c |
```

### Task lists

```markdown
- [x] Done
- [ ] Todo
```

### Strikethrough

```markdown
~~deleted text~~
```

## Project structure

```text
src/
├── ast/            AST definitions, parsers, visitors, and tests
├── config/         JavaScript option conversion
├── frontmatter.rs  YAML front-matter extraction
├── html.rs         HTML renderer
└── lib.rs          Public Rust API + Node.js N-API bindings
```

The generated TypeScript API is available in [index.d.ts](index.d.ts);
the Rust API is documented in the source and via `cargo doc`.