npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

foldkit-richtext-markdown

v0.3.2

Published

Print a foldkit-richtext document as CommonMark, with a diagnostic for what the mapping cannot express.

Readme

foldkit-richtext-markdown

Read and write Markdown for a foldkit-richtext document.

Markdown is an interpreter over the semantic document, not a second document model. This package maps a document to CommonMark — with GFM's lists, tasks, strikethrough, and tables — reads the same set back, and reports what a mapping cannot express instead of dropping it silently.

Printing needs nothing beyond this package. Parsing uses micromark and mdast, the parser stack the design asks for. Both directions return diagnostics, so a caller can refuse, warn, or show a placeholder rather than lose content.

Install

pnpm add foldkit-richtext-markdown

What it owns

The Markdown syntax, and nothing else. The document stays foldkit-richtext's, the editable subtree stays foldkit-richtext-dom's, and no state lives here: print is a pure function of a Document, and a source session is a value the application keeps.

Print a document

import * as RichText from 'foldkit-richtext'
import { print } from 'foldkit-richtext-markdown'

const document = RichText.decodeDocument({
  version: 1,
  children: [
    {
      type: 'Paragraph',
      id: 'p',
      children: [{ type: 'Text', id: 't', text: 'hello', marks: [] }],
    },
  ],
})

const { markdown, diagnostics } = print(document)

markdown // 'hello\n'
diagnostics // []

diagnostics names every kind or mark the mapping has no syntax for. A block kind with no syntax prints its content rather than losing it; a preserved Unknown block is reported and skipped, because its payload is opaque.

| Document | Markdown | | --- | --- | | Paragraph, Heading | the text, and #…###### | | Quote | > on every line, a blank one included | | List, ListItem, TaskItem | - / 1. and - [x]; a nested block stays aligned under its marker, and a list right after another of the same kind takes the other marker (*, )) so it reads back as its own list | | CodeBlock | a fence, its language, the text verbatim, and a fence longer than any backticks inside | | ThematicBreak | --- | | Image | ![alt](src) on its own line | | FootnoteRef on a run | [^label] in place of the label the run shows | | FootnoteDefinition | [^label]: and its first block, later lines indented four | | Table, TableRow, TableCell | a GFM pipe table, the first row as its header (TableRow.header) | | Bold, Italic, Code, Strikethrough, Link | **, *, backticks, ~~, [label](href), with the link outermost |

Text is escaped so it cannot become markup: a backslash before an inline delimiter (and before ! and |, which could start an image or a table), and before a block marker — or a 1. — that would open a paragraph's line. A leading space becomes  . Where CommonMark would not let a delimiter open or close because punctuation sits on one side and a letter on the other (x**(a)**y), the letter is written as a character reference (x**(a)**y), which reads back as the same letter. An empty task item prints as - [ ], which GFM alone would read as text; parse reads an item holding only [ ] or [x] back as that empty task.

Parse Markdown

import { parse } from 'foldkit-richtext-markdown'

let n = 0
const { document, diagnostics } = parse('> quoted\n', { mint: () => `id-${++n}` })

mint supplies every identity, as it does everywhere else in the library, and the document is decoded through the codec, so a bad construction fails loudly instead of reaching an editor.

It reads CommonMark, plus GFM's task lists, strikethrough, and tables, and with footnotes: true its footnotes, as foldkit-richtext's FootnoteRef and FootnoteDefinition for a kit that declares them; without it a footnote is reported and skipped. It reports instead of guessing: raw HTML, a link definition (a reference to one keeps its text, unlinked), a hard line break (which becomes its own paragraph, because a block holds no line break), and an image inside a paragraph (Image is a block, so a paragraph holding only one is hoisted to it). Markdown is as untrusted as pasted HTML, so a link's or an image's URL passes the same safeUrl policy HTML import uses: a refused link keeps its text unlinked, a refused image is dropped, and each is reported as UnsafeUrl.

print(parse(markdown)) returns the same Markdown and parse(print(document)) a document that prints the same, which is how the two directions are tested against each other.

The same Markdown means the canonical spellings: * for emphasis, - for a bullet, backtick fences, # headings. Text written as _hello_, * item, or a heading underlined with === is kept that way by the style parse also returns — which spelling each construct took, first occurrence each, and never part of the document — handed back to print:

const parsed = parse('_hello_\n\n* item\n', { mint: () => `id-${++n}` })
print(parsed.document, { style: parsed.style }).markdown // '_hello_\n\n* item\n'

Blocks also keep their own spelling. style.blocks maps the id parse gave each list, heading, fence, and rule to how that one was written, so a text with a - list and a * list, or a setext title over ## sections, prints back as it was. A block the text did not have, such as a list added in the rich editor, takes its construct's spelling. A list's spacing is kept the same way: a loose list, one with a blank line between its items or inside one, prints with a blank line between its items and their blocks, and a tight one with none, so a nested list sits right under its item's text. Without a style, lists print tight. Emphasis stays per construct, because runs split and merge as they are edited and their ids do not last.

A spelling that would change what the text means somewhere — _ does not open emphasis inside a word — is not used: print reads its styled text back, and when that reads as a different document than the canonical text, the canonical text is what it returns.

Input rules

markdownInputRules reshapes a block when a marker is completed at its start: # through ###### retype it as a heading (RetypeBlock), and > , - /* /+ , and an ordered marker such as 1. or 3) wrap it in a quote or a list (WrapBlock), an ordered list numbered from the number typed, and a marker typed right after a list of the same kind adds an item to it; and a fence with an optional language, such as ```ts then a space, converts it to a CodeBlock (ConvertBlock). [ ] or [x] at the start of a list item, which - has made by then, makes the item a task where it stands, unticked or ticked (RetypeContainer), in a bulleted or an ordered list, with any list nested in it. Typed in a later paragraph of the item, the marker stays text, as Markdown reads it.

The inline markers close as they are typed (markdownInlineRules, which markdownInputRules ends with): **bold**, *italic* and _italic_, `code`, and ~~struck~~ become marked text the moment the closing marker is typed, and what is typed next is plain again. The content is not empty, does not start or end with a space, and holds no delimiter character, and the opening marker stands at the block's start or after a space, an opening bracket or a quote, so snake_case_name and 2*3*4 stay text. Inside a CodeBlock, which forbids marks, the markers stay the text they are. An editor placement in foldkit-richtext-dom names the rules it applies, so this package holds no editor state and the editor holds no Markdown:

import { editorAt } from 'foldkit-richtext-dom/editor-bundle'
import { markdownInputRules } from 'foldkit-richtext-markdown'

const body = editorAt('article-body', { inputRules: markdownInputRules })

A fence is completed by a space, not by the line break Markdown reads it at, because Enter splits a block and a rule sees only what is typed. Converting gives the block's runs new identities. Under the standard vocabulary, whose CodeBlock forbids marks, a fence typed into a block that carries marks is refused, and the editor keeps the fence as the text it typed. A list marker typed right after a list starts a new list beside it rather than adding an item to that one.

Edit the Markdown itself

A source session lets a person edit the document as Markdown and switch back. It is a value, not a store: the application keeps it in its Model while source mode is on, and the rich document stands still meanwhile, so only one representation is ever being edited.

import { closeSource, openSource } from 'foldkit-richtext-markdown'

// Entering source mode: the draft starts as the printed document.
const session = openSource(model.document)

// Each keystroke in the source editor replaces the draft.
const edited = { ...session, draft: '# Title\n\nNew text\n' }

// Leaving it: a pure read, so it serves as a preview too.
const closed = closeSource(edited, model.document, { mint: () => crypto.randomUUID() })

openSource prints the document and keeps what the printer could not show — a mark Markdown has no syntax for — as unprintable. closeSource compares the draft with what was printed: unedited, it returns the caller's own document untouched (changed: false), identities and unprintable marks included, so switching modes without typing loses nothing. Edited, it parses the draft into a new document and reports, in diagnostics, the unprintable content the edit loses followed by what parsing refused. Nothing is committed until the application puts closed.document in its Model, so it can warn first. SourceSession is a schema, so the session fits in a Model as it is.

closed.style is how the edited draft spelled its constructs, over the style the session opened with; its blocks are the edited draft's alone, since the blocks it names are new. Keep it beside the document and pass it to the next openSource(document, { style }), and a writer who typed _hello_ and * item sees them spelled that way when they come back to source mode, rather than in the canonical *hello* and - item.

The caret comes along both ways. openSource(document, { selection }) sets the session's caret, an offset in the draft, to where the selection's focus printed; a node selection, or none, starts it at 0. Replace caret as it moves in the source editor, and closed.selection is that place in the document closeSource returns: in the parsed draft when it was edited, and in the caller's own document when it was not. It is null when the draft holds no text, or when an unedited draft does not have as many text blocks as the caller's document. Keep the old selection then.

Limits

  • Smaller losses in a round trip. A code block's trailing blank lines, a heading's leading space, the words after a fence's language (```ts title), and a line break inside a setext heading do not survive; a list item that is only a rule reads back as a rule; a row with fewer cells than the widest is padded. Each is reported by nothing yet.

  • A table's header. GFM's header is the first row, so that row is printed as the header whether or not the document marks one; a row marked as the header anywhere else is reported, because GFM cannot place it. A cell's blocks are inlined with a space between them.

  • Inline atoms. The model has no inline image or break, so an Image is a block and prints as its own line — which a parser reads back as a paragraph holding an image.

  • A link with no href prints as its label, with an UnsupportedMark diagnostic.

  • A mark on whitespace at a run's edge. **bold ** is not emphasis in CommonMark, so a marked run's leading and trailing whitespace is printed outside its delimiters: the text survives, and the space reads back unmarked.

  • A bare URL in text. GFM links https://…, www.…, and email addresses written as plain text, and no escape stops it, so such text reads back as a link.

  • A hard line break has no shape inside a block, so it ends the paragraph and the rest starts a new one, with a diagnostic. Raw HTML and link definitions are reported and skipped: a document holds neither. Footnotes are too, unless parse is given footnotes: true; a footnote label GFM cannot spell (one with a space) prints as text, reported.

  • No profile yet. Custom syntax needs both directions at once, so it arrives with the first application that declares one.