Reference

Divergences

Sätteri aims to match remark, @mdx-js/mdx, and the wider unified ecosystem in the ASTs and output it produces.

Typically, differences are unwanted and are bugs to be fixed. However, in certain cases the differences might be more beneficial. For example, remark might have some sort of old quirk or a bug that wasn't found, or couldn't be fixed easily for some reason. In such cases, Sätteri might choose to diverge from the reference behaviour.

AST

Unclosed frontmatter delimiters

When remark-frontmatter sees --- or +++ at line 1 and can't find a matching close, it suppresses list and blockquote detection for the rest of the document. Sätteri doesn't.

---

- this is a list, not paragraph text
ParserOutput
remark-parse + remark-frontmatterthematicBreak + paragraph(- this …)
Sätteri (with frontmatter feature on)thematicBreak + list

A bare URL or email only becomes a link when the character before it is whitespace, punctuation, or the start of the text. remark-gfm inspects that character one UTF-16 code unit at a time, so a character outside the Basic Multilingual Plane never counts as punctuation and blocks the link. Sätteri classifies the whole code point.

😀www.example.com
ParserOutput
remark-parse + remark-gfmtext (😀www.example.com)
Sätteritext (😀) + link (http://www.example.com)

This only affects astral punctuation and symbols — 𐄁 (U+10101), 😀 (U+1F600), 𝛛 (U+1D6DB) and the like. Astral characters that aren't punctuation, such as the digit 🯰 (U+1FBF0), block the link on both sides. A ZWJ sequence is judged by its last code point, so 👨‍💻 behaves like the 💻 that ends it.

remark-gfm emits some GFM autolinks — the ones a preceding [ keeps it from tokenizing — with no position at all, on the link, its text child, or the text nodes around it. Sätteri reports the source span for every autolink.

[www.example.com
ParserOutput
remark-parse + remark-gfmlink with no position
Sätterilink with position spanning www.example.com

URLs are unchanged: [www.example.com/&b links to http://www.example.com/&b on both sides, and the position covers the raw &. Smart punctuation works the same way: the span covers the raw --, ... or " run the value was rendered from — though under smartPunctuation such a link's URL carries the rewritten character, so [www.example.com/x-- links to http://www.example.com/x– where the tokenized www.example.com/x-- keeps the raw --. When a match starts or ends inside a character reference that decodes to more than one character, no exact span exists, and Sätteri reports no position rather than an approximate one.

Paragraph start in a task list item

mdast-util-gfm-task-list-item pulls the paragraph's start back over the [ ] checkbox, but only when the paragraph's first child is a text node. Sätteri always starts the paragraph after the checkbox.

- [ ] _e_
Parserparagraph span
remark-parse + remark-gfmoffsets 2–9
Sätterioffsets 6–9

For - [ ] plain text remark reports offsets 6–16, agreeing with Sätteri — so on its side the reported start depends on what follows the checkbox. Sätteri uses one rule for every first child. The same difference appears with `c`, <b>i</b> and ![a](/b), and for the *, - and 1. markers alike.

Rendering

Code block data.lang

Sätteri keeps the fenced-code info-string language on the HAST element as data.lang. remark-rehype drops it, presumably on the grounds that it's already encoded as properties.className (ex, language-rust).

```rust title=foo.rs
fn main() {}
```
ParserHAST data
remark-rehype{ meta: "title=foo.rs" }
Sätteri{ lang: "rust", meta: "title=foo.rs" }

Both still emit class="language-rust" on the <code> element, so syntax-highlighting plugins that read properties.className are unaffected. Plugins that want the raw language without parsing it back out of the class name can read data.lang directly.

Unknown directives in HAST

Sätteri drops containerDirective, leafDirective, and textDirective nodes when converting mdast to hast unless the node has data.hName set by a plugin. mdast-util-to-hast's defaultUnknownHandler instead wraps unknown nodes in a <div> and recurses into their children.

:::tip[Title] content :::
PipelineHTML
remark-directive<div><p>Title</p><p>content</p></div>
Sätteri(empty, node dropped)

Generic directives without a handler aren't meant to render anything meaningful (remark-directive's own README says "Doesn't handle the directives: create your own plugin to do that"), and the <div> wrapper discards the directive's name, so the resulting HTML is no more useful than dropping the node. Plugins that do set data.hName work identically on both sides.

Table cell alignment

GFM tables with column alignment produce different HAST properties.

| right |
| ----: |
|     1 |
ParserHAST output
remark-rehype<th align="right">right</th>
Sätteri<th style="text-align: right">right</th>

The HTML renders identically. align is deprecated in HTML5 and style is the modern equivalent, so Sätteri emits style. A HAST plugin that reads properties.align won't find anything; read properties.style or normalize at the boundary.

Smart punctuation pairing across nodes

With the smartPunctuation feature on, Sätteri converts straight quotes to typographic quotes, based on Smartypants.

remark-smartypants processes mdast text node on its own. An inline node between two quotes puts them in separate text nodes, so remark-smartypants never pairs them and leaves them straight. Sätteri, on the other hand, looks for pairs of quotes across inline nodes and converts them to curly quotes.

a "_quoted_" word
PipelineOutput
remark-smartypantsa "*quoted*" word
Sätteri (with smartPunctuation)a “*quoted*” word

MDX

oxc vs acorn differences

Sätteri parses MDX expressions with oxc; @mdx-js/mdx uses acorn. The two disagree on some edge cases. We treat oxc's behaviour as correct and don't consider these differences bugs.