` inside a list item or a blockquote that holds a single paragraph. - **`headingIds`** adds an `id` to every heading. ### Document model - **Tight and loose lists** aren't distinguished. List items render the same way with or without blank lines between them. - **Empty link text** removes the link: `[](/url)` renders nothing. - **Image alt text** keeps the raw Markdown: `` gives the alt text `foo *bar*`. - **Markdown inside an HTML block**, such as a `
` element. ### GFM extensions - **Email autolinks** such as `foo@bar.baz` stay plain text. URL autolinks such as `www.example.com` work. - **The tag filter** isn't applied: raw ``, ` ``` ```tsx [React] import { Markdown } from '@comark/react' import shiki from '@comark/react/plugins/shiki' import githubLight from '@shikijs/themes/github-light' import githubDark from '@shikijs/themes/github-dark' {content} ``` :: --- ## Features ### Dual-theme support Highlight code with different themes for light and dark modes. Both palettes are embedded as CSS custom properties, so there is no flash on theme switch. See all [available themes →](https://shiki.style/themes) ```typescript shiki({ themes: { light: githubLight, dark: githubDark } }) ``` ### Language detection Comark reads the language from the code fence info string and highlights accordingly. On the standard entry, the default language set is pre-registered; pass extra grammars via `languages` (from `@shikijs/langs`). On `core`, only the languages you pass are available. See all [180+ supported languages →](https://shiki.style/languages) ~~~markdown ```typescript const x: number = 42 ``` ~~~ ### Inline code Inline code is highlighted when it declares a language with the attributes syntax. `lang` wins over `language`: ```markdown The type is `Ref`{lang="ts-type"} and the component is ` `{lang="vue-html"}. ``` Two fragment languages ship out of the box: `ts-type` tokenizes through the TypeScript grammar seeded with `let a:`, and `vue-html` through the Vue grammar seeded with ``. Without that seed a bare type or a bare tag falls through to plain text. The ` ` element gets the same `shiki` class a `` gets, so the dual-theme CSS in [Styling](#styling) covers it with no extra rules. Inline code uses the fast token path, so `transformers` and `preStyles` do not apply to it. Inline code naming a grammar that is not registered is left exactly as it was written, with no class and no spans. `lang` is a real HTML attribute for natural language, so ```Bonjour`{lang="fr"}`` must not be treated as code. A fenced block behaves differently and still falls back to an unhighlighted `.shiki` block, because a `` is unambiguously code. Set `inlineCode: false` to turn this off. ### Line highlighting Highlight specific lines using `{line-numbers}` syntax: ~~~markdown ```javascript {2-3,5} function example() { const a = 1 // highlighted const b = 2 // highlighted const c = 3 return a + b + c // highlighted } ``` ~~~ Lines receive the `.highlight` class; see [Styling](#styling) for the required CSS. ### Filename metadata Display a filename label above the code block: ~~~markdown ```javascript [server.js] const app = express() ``` ~~~ ### Language loading Install `@shikijs/langs` and import grammars to register extra languages (or all languages on `core`): ```bash [terminal] npm install @shikijs/langs ``` ```typescript import javascript from '@shikijs/langs/javascript' import typescript from '@shikijs/langs/typescript' import python from '@shikijs/langs/python' shiki({ languages: [javascript, typescript, python] }) ``` ::tip **Standard:** default languages are pre-registered; `languages` merges on top. Use `registerDefaultLanguages: false` to replace the set entirely. **Core:** no defaults — `languages` is required and is the full set (plus the built-in Comark/`mdc` grammar). :: ### Transformers Pass any [Shiki transformer](https://shiki.style/guide/transformers) via `transformers` to add diff annotations, focus lines, or custom classes: ```typescript import { transformerNotationDiff } from '@shikijs/transformers' shiki({ themes: { light: githubLight, dark: githubDark }, transformers: [transformerNotationDiff()] }) ``` The most powerful transformer is [`@shikijs/twoslash`](https://comark.dev/kb/twoslash): it runs the TypeScript compiler on your code blocks to add inline type tooltips and error annotations. ### Pre styles Set `preStyles: true` to add inline background and foreground colors to `` elements based on the active theme. --- ## API ### `shiki(options?)` — standard entry ```typescript import shiki from 'comark/plugins/shiki' // shiki(options?: ShikiOptions): ComarkPlugin ``` Returns a `ComarkPlugin` with bundled Material themes and the default language set. Options are optional. ### `shiki(options)` — core entry ```typescript import shiki from 'comark/plugins/shiki/core' // shiki(options: ShikiCoreOptions): ComarkPlugin ``` Returns a `ComarkPlugin` with **no** bundled themes or languages. `themes` and `languages` are required. Import them from `@shikijs/themes` and `@shikijs/langs`. --- ## Options Two option types, one per entry: - **`ShikiOptions`** — `comark/plugins/shiki` (standard). Themes/languages optional; includes `registerDefaultThemes` / `registerDefaultLanguages`. - **`ShikiCoreOptions`** — `comark/plugins/shiki/core`. `themes` and `languages` are **required**; no `registerDefault*` options (nothing is bundled by default). ### Standard (`ShikiOptions`) | Option | Type | Default | Description | | --------------------------------------------------------------- | ------------------------------------------------------- | --------------- | -------------------------------------------------- | | [`themes`](#options-themes) | `object` | Material themes | Light and dark theme registrations | | [`languages`](#options-languages) | `Array` | `undefined` | Extra languages (merged onto the default set) | | [`transformers`](#options-transformers) | `ShikiTransformer[]` | `undefined` | Shiki transformers applied to every block | | [`preStyles`](#options-prestyles) | `boolean` | `false` | Add inline background/foreground styles to ` ` | | [`inlineCode`](#options-inlinecode) | `boolean` | `true` | Highlight inline code that declares a language | | [`registerDefaultLanguages`](#options-registerdefaultlanguages) | `boolean` | `true` | Register the built-in default language set | | [`registerDefaultThemes`](#options-registerdefaultthemes) | `boolean` | `true` | Register the built-in Material themes | ### Core (`ShikiCoreOptions`) | Option | Type | Default | Description | | -------------- | ------------------------------------------------------- | ------------ | -------------------------------------------------- | | `themes` | `object` | **required** | Light and/or dark theme registrations | | `languages` | `Array` | **required** | Languages to register | | `transformers` | `ShikiTransformer[]` | `undefined` | Shiki transformers applied to every block | | `preStyles` | `boolean` | `false` | Add inline background/foreground styles to ` ` | | `inlineCode` | `boolean` | `true` | Highlight inline code that declares a language | ### `themes` Theme configuration for light and dark modes. Install [`@shikijs/themes`](https://www.npmjs.com/package/@shikijs/themes) and import from there: ```bash [terminal] npm install @shikijs/themes ``` ```typescript import githubLight from '@shikijs/themes/github-light' import githubDark from '@shikijs/themes/github-dark' shiki({ themes: { light: githubLight, dark: githubDark } }) ``` **Standard default:** `{ light: materialThemeLighter, dark: materialThemePalenight }` (when `registerDefaultThemes` is true). **Core:** required (at least one of `light` / `dark`). ### `languages` Languages to register. Install [`@shikijs/langs`](https://www.npmjs.com/package/@shikijs/langs) and import from there. On the standard entry, values are merged on top of the default set when `registerDefaultLanguages` is true. ```bash [terminal] npm install @shikijs/langs ``` ```typescript import javascript from '@shikijs/langs/javascript' import typescript from '@shikijs/langs/typescript' shiki({ languages: [javascript, typescript] }) ``` **Standard default:** `undefined` (default set still registered via `registerDefaultLanguages`). **Core:** required. ### `transformers` An array of [Shiki transformers](https://shiki.style/guide/transformers) applied to every highlighted block. ```typescript import { transformerNotationDiff, transformerNotationHighlight } from '@shikijs/transformers' shiki({ transformers: [ transformerNotationDiff(), // [!code ++] / [!code --] transformerNotationHighlight(), // [!code highlight] ] }) ``` **Default:** `undefined` ### `preStyles` Add inline background and foreground color styles to `` elements based on the active theme. ```typescript shiki({ preStyles: true }) ``` **Default:** `false` ### `inlineCode` Whether to highlight inline code that declares a language, e.g. ```Ref`{lang="ts-type"}``. See [Inline code](#inline-code). ```typescript shiki({ inlineCode: false }) ``` **Default:** `true` ### `registerDefaultLanguages` Standard entry only. When `true`, these languages are pre-registered: `vue`, `tsx`, `svelte`, `astro`, `typescript`, `javascript`, `bash`, `json`, `yaml` (plus the built-in Comark/`mdc` grammar). Set to `false` to control the language set entirely via `languages`. ```typescript shiki({ registerDefaultLanguages: false, languages: [javascript, typescript] }) ``` **Default:** `true` ### `registerDefaultThemes` Standard entry only. When `true`, registers `material-theme-lighter` (light) and `material-theme-palenight` (dark). Set it to `false` to skip loading those themes at runtime. To keep their import chunks out of a consumer bundle entirely, use `comark/plugins/shiki/core` instead. ```typescript shiki({ registerDefaultThemes: false, themes: { light: githubLight, dark: githubDark } }) ``` **Default:** `true` --- ## Examples ### GitHub theme ```typescript import { parseMarkdown } from 'comark' import shiki from 'comark/plugins/shiki' import githubLight from '@shikijs/themes/github-light' import githubDark from '@shikijs/themes/github-dark' const result = await parseMarkdown(content, { plugins: [shiki({ themes: { light: githubLight, dark: githubDark } })] }) ``` ### Minimal bundle Install the standalone Shiki packages, use the `core` entry (`ShikiCoreOptions`), and import only what you need — `languages` and `themes` are required, and no defaults are bundled: ```bash [terminal] npm install shiki @shikijs/langs @shikijs/themes ``` ```typescript import shiki from 'comark/plugins/shiki/core' import javascript from '@shikijs/langs/javascript' import typescript from '@shikijs/langs/typescript' import githubDark from '@shikijs/themes/github-dark' shiki({ languages: [javascript, typescript], themes: { dark: githubDark } }) ``` ### With transformers ```typescript import { transformerNotationDiff, transformerNotationHighlight, transformerNotationFocus, } from '@shikijs/transformers' shiki({ themes: { light: githubLight, dark: githubDark }, transformers: [ transformerNotationDiff(), // [!code ++] / [!code --] transformerNotationHighlight(), // [!code highlight] transformerNotationFocus(), // [!code focus] ] }) ``` See the [Twoslash guide](https://comark.dev/kb/twoslash) for TypeScript-powered type tooltips and error annotations in code blocks. ::tip{to="https://comark.dev/kb/custom-code-block"} Need a copy button or collapse threshold on a custom `ProsePre`? After highlighting there is no `code` prop — reconstruct the source with `__node` and `textContent()`. :: ### Live examples ::card{icon="i-lucide-code" title="Vue + Vite Highlight" to="https://github.com/comarkjs/comark/tree/main/examples/3.plugins/vue-vite-highlight"} Dual-theme support, 10+ languages, theme toggle. Includes JavaScript, TypeScript, Python, Rust, Go, SQL and more. :: ::card{icon="i-simple-icons-typescript" title="Vue + Vite Twoslash" to="https://github.com/comarkjs/comark/tree/main/examples/3.plugins/vue-vite-twoslash"} Browser-side twoslash with CDN-fetched TypeScript types and interactive type popups. :: --- ## Styling Shiki outputs tokens as `` elements inside a ` ` block. Highlighted inline code gets the same `shiki` class on the `` element, with the token spans directly inside it and no `.line` wrapper. ### Line highlight Lines set with `{1,3-5}` syntax receive the `.highlight` class: ```css .shiki span.line.highlight { background-color: rgba(255, 255, 0, 0.1); display: inline-block; width: calc(100% + 2rem); margin: 0 -1rem; padding: 0 1rem; } ``` ### Dark mode When both `light` and `dark` themes are provided, Shiki embeds both palettes as CSS custom properties on every ``. Activate the dark palette based on your project's dark-mode class: ```css html.dark .shiki span { color: var(--shiki-dark) !important; background-color: var(--shiki-dark-bg) !important; font-style: var(--shiki-dark-font-style) !important; font-weight: var(--shiki-dark-font-weight) !important; text-decoration: var(--shiki-dark-text-decoration) !important; } ``` In Vue scoped styles, use `:deep()` to reach Shiki spans: ```vue ``` --- - [Parse API](https://comark.dev/reference/parse) - [Twoslash](https://comark.dev/kb/twoslash) - [Custom Pre](https://comark.dev/kb/custom-code-block) # Summary extraction > Plugin for extracting content summaries using the \ delimiter. The `comark/plugins/summary` plugin extracts content before a `` comment and stores it in `tree.meta.summary`. Useful for blog listings, article previews, RSS feeds, and anywhere you need a short excerpt of the full content. ## Usage ~~~typescript import { parseMarkdown } from 'comark' import summary from 'comark/plugins/summary' const content = `# Article Title This is the introduction that will become the summary. This is the full article content. ` const result = await parseMarkdown(content, { plugins: [summary()] }) console.log(result.meta.summary) // Node[]: nodes before console.log(result.nodes) // full content ~~~ With framework components: ::code-group ```vue [Vue]{{ content }} ``` ```tsx [React] import { parseMarkdown } from 'comark' import { MarkdownDocument } from '@comark/react' import summary from '@comark/react/plugins/summary' const doc = await parseMarkdown(content, { plugins: [summary()] }) // renders only the summary portion``` :: ::tip The `summary` prop on the Vue ` ` component renders only the extracted summary nodes; without it, the full content is rendered and `meta.summary` is available separately. In React, read `meta.summary` from the parse result and render it with ` `. :: --- ## API ### `summary(options?)` Returns a `ComarkPlugin` that extracts content before the delimiter. **Parameters:** - `options?` - Optional configuration, see [Options](#options) **Returns:** `ComarkPlugin` The extracted nodes are stored at `tree.meta.summary` as `Node[]`. If no delimiter is found in the content, `meta.summary` is not set. --- ## Options | Option | Type | Default | Description | | --------------------------------- | -------- | ----------------- | ---------------------------------------------------- | | [`delimiter`](#options-delimiter) | `string` | `''` | HTML comment used to split summary from full content | ### `delimiter` The HTML comment string that marks the end of the summary. The extracted `meta.summary` nodes stop before the delimiter; the full content in `nodes` keeps the delimiter's comment node. ```typescript summary({ delimiter: '' }) ``` --- ## Examples ### Blog listing Render summaries in a listing page and link to the full article: ::code-group ```vue [Vue] ``` ```tsx [React] import { parseMarkdown } from 'comark' import { MarkdownDocument } from '@comark/react' import summary from '@comark/react/plugins/summary' const plugins = [summary()] export async function ArticleList({ articles }) { const docs = await Promise.all( articles.map(article => parseMarkdown(article.content, { plugins })) ) return ({{ article.title }}
{{ article.content }} Read more →{articles.map((article, i) => () } ``` :: --- - [Parse API](https://comark.dev/reference/parse) - [Plugins](https://comark.dev/plugins) # Table of contents > Plugin for automatically generating a table of contents from document headings. The `comark/plugins/toc` plugin generates a hierarchical table of contents from document headings and stores it in `tree.meta.toc`. Headings `h2` through `h6` are included; `h1` is always excluded. ## Usage ```typescript import { parseMarkdown } from 'comark' import toc from 'comark/plugins/toc' const result = await parseMarkdown(content, { plugins: [toc()] }) console.log(result.meta.toc) // Toc ``` --- ## API ### `toc(options?)` Returns a `ComarkPlugin` that generates a hierarchical TOC from headings. **Parameters:** - `options?` - Optional configuration, see [Options](#options) **Returns:** `ComarkPlugin` The result is stored at `tree.meta.toc`: ```typescript interface Toc { title: string // TOC title (from the `title` option) depth: number // Maximum heading depth included searchDepth: number // Search depth in nested structures links: TocLink[] // Hierarchical list of links } interface TocLink { id: string // Heading ID for anchor links text: string // Heading text depth: number // Heading level (2–6) children?: TocLink[] // Nested child headings } ``` --- ## Options | Option | Type | Default | Description | | ------------------------------------- | -------- | ------- | ------------------------------------------------------------------------ | | [`depth`](#options-depth) | `number` | `2` | Heading levels to include: `1` = h2 only, `2` = h2–h3, `3` = h2–h4, etc. | | [`searchDepth`](#options-searchdepth) | `number` | `2` | How deep to search for headings in nested component structures | | [`title`](#options-title) | `string` | `''` | Title field on the returned `Toc` | ### `depth` Controls which heading levels to include. Maps to heading levels: `1` = h2 only, `2` = h2–h3, `3` = h2–h4, up to `5` = h2–h6. ```typescript toc({ depth: 3 }) // include h2, h3, and h4 ``` **Default:** `2` ### `searchDepth` Controls how deep to search for headings in nested component structures. Increase when headings are inside components or containers. ```typescript toc({ searchDepth: 3 }) ``` **Default:** `2` ### `title` Sets the `title` field on the returned `Toc`. Has no effect on rendering; use it to label the TOC in your layout component. ```typescript toc({ title: 'On This Page' }) ``` **Default:** `''` --- ## Examples ### Basic TOC generation ::code-group ~~~typescript [Parse API] import { parseMarkdown } from 'comark' import toc from 'comark/plugins/toc' const content = `## Introduction ## Features ### Performance ### Flexibility ## Conclusion ` const result = await parseMarkdown(content, { plugins: [toc()] }) console.log(result.meta.toc) ~~~ ```json [Output] { "title": "", "depth": 2, "searchDepth": 2, "links": [ { "id": "introduction", "text": "Introduction", "depth": 2 }, { "id": "features", "text": "Features", "depth": 2, "children": [ { "id": "performance", "text": "Performance", "depth": 3 }, { "id": "flexibility", "text": "Flexibility", "depth": 3 } ] }, { "id": "conclusion", "text": "Conclusion", "depth": 2 } ] } ``` :: ### Docs layout Parse once to get the TOC, then render it alongside the `))} {article.title}
Read more → ` component: ::code-group ```vue [Vue] ``` ```tsx [React] import { useEffect, useState } from 'react' import { parseMarkdown } from 'comark' import { Markdown } from '@comark/react' import toc from '@comark/react/plugins/toc' const plugins = [toc({ depth: 3 })] export function DocsLayout({ content }: { content: string }) { const [tocData, setTocData] = useState{{ props.content }} (null) useEffect(() => { parseMarkdown(content, { plugins }).then(result => setTocData(result.meta.toc)) }, [content]) return ( ) } ``` :: ::tip For deeply nested TOCs, render `link.children` recursively: define a `TocLink` component that calls itself for each child. :: --- - [Parse API](https://comark.dev/reference/parse) - [Document Model](https://comark.dev/getting-started/document-model) # Plugin API > Type-safe API for creating Comark plugins with pre/post lifecycle hooks. The `comark/parse` module exports `defineComarkPlugin`, a typed factory wrapper for building plugins that extend the parser. ## `defineComarkPlugin(factory)`{lang="ts"} Wraps a plugin factory function to provide type safety for both options and the returned plugin. **Parameters:** - `factory` - A function `(options?: O) => ComarkPlugin` where `O` is an optional options type. When the plugin is instantiated, `options` receives the values passed by the caller. See [`ComarkPlugin`](#comarkplugin) for the full shape of the returned object. **Type parameters:** | Parameter | Default | Description | | -------------- | --------- | ---------------------------------------------------------------------------------------------------------------- | | `Options` | `unknown` | Shape of the options the factory accepts. | | `TMeta` | `{}` | Keys this plugin contributes to `tree.meta`. Surfaces on the `parse` result and constrains writes inside `post`. | | `TFrontmatter` | `{}` | Keys this plugin contributes to `tree.frontmatter`. This is experimental and may change in future versions. | **Returns:** A typed plugin factory `(options?: O) => ComarkPlugin{content} ` **Example:** ```typescript import { defineComarkPlugin } from 'comark/parse' export default defineComarkPlugin(() => ({ name: 'my-plugin', pre(state) {}, post(state) {}, })) ``` With typed options: ```typescript import { defineComarkPlugin } from 'comark/parse' interface MyOptions { prefix?: string } export default defineComarkPlugin((options: MyOptions = {}) => ({ name: 'my-plugin', post(state) { // options.prefix is typed }, })) ``` --- ## Typed contributions Plugins that write to `tree.meta` can declare what they contribute via the second and third type parameters of `defineComarkPlugin`. Two things follow from that declaration: 1. **Inside `post(state)`**, `state.tree.meta` and `state.tree.frontmatter` are typed as the declared shape, so writes are checked against it. 2. **At the `parse` callsite**, the contribution is intersected into the resulting `tree.meta` / `tree.frontmatter`: `result.meta.toc` reads back as the declared type instead of `any`. ### Declaring a contribution ```typescript import { defineComarkPlugin } from 'comark/parse' interface TocTree { title: string depth: number links: { id: string; text: string }[] } export default defineComarkPlugin<{}, { toc: TocTree }>(() => ({ name: 'toc', post(state) { state.tree.meta.toc = { title: 'Contents', depth: 2, links: [] } // @ts-expect-error wrong value type for the declared key state.tree.meta.toc = 123 // @ts-expect-error `summary` was not declared in the contribution state.tree.meta.summary = [] }, })) ``` A plugin that contributes more than one key lists them all in the same object literal: ```typescript defineComarkPlugin (() => ({ name: 'headings', post(state) { state.tree.meta.title = 'Hello' state.tree.meta.description = undefined }, })) ``` ### Inference at the `parse` callsite `parse` and `createMarkdownParser` infer the plugins tuple and intersect each plugin's contribution into the result type. No explicit generic argument is needed: ```typescript import { parseMarkdown } from 'comark' import toc from 'comark/plugins/toc' import summary from 'comark/plugins/summary' const tree = await parseMarkdown(content, { plugins: [toc(), summary()] }) tree.meta.toc // Toc tree.meta.summary // Node[] tree.meta.something // unknown: undeclared keys are typed as unknown ``` ### Back-compat for unannotated plugins Plugins that omit the meta/frontmatter type parameters keep the pre-typed free-form behavior: `state.tree.meta` is `Record ` and any key can be written. The result type on the `parse` callsite also stays `Record ` when no plugin declared a contribution (or when `plugins` is passed as a widened `ComarkPlugin[]` variable rather than an inline array). ```typescript // Old style: still works, no narrowing defineComarkPlugin(() => ({ name: 'wordcount', post(state) { state.tree.meta.wordCount = 42 // ok, no declared contribution }, })) ``` --- ## Lifecycle Plugins hook into two phases of the parsing pipeline: ``` Markdown string │ ▼ ┌──────────┐ │ pre() │ ← Modify raw markdown before parsing └──────────┘ │ ▼ Parse & Build MarkdownDocument │ ▼ ┌──────────┐ │ post() │ ← Transform the AST after parsing └──────────┘ │ ▼ MarkdownDocument ``` ### `pre(state)`{lang="ts"} Called before markdown is tokenized. Use it to transform the raw markdown string. ```typescript import { defineComarkPlugin } from 'comark/parse' export default defineComarkPlugin(() => ({ name: 'strip-comments', pre(state) { state.markdown = state.markdown.replace(//g, '') }, })) ``` **`ComarkParsePreState`:** | Field | Type | Description | | ---------- | ------------------------------------------------------------- | ----------------------------------------------- | | `markdown` | `string` | The raw markdown; modify to change parser input | | `options` | [`ParserOptions`](https://comark.dev/reference/parse#options) | The parser configuration | ### `post(state)`{lang="ts"} Called after the AST is built. Use it to traverse nodes or populate `meta` with extracted data. ```typescript import { defineComarkPlugin } from 'comark/parse' import { visit } from 'comark/utils' export default defineComarkPlugin(() => ({ name: 'word-count', post(state) { let count = 0 visit(state.tree, (node) => typeof node === 'string', (node) => { count += (node as string).split(/\s+/).filter(Boolean).length } ) state.tree.meta.wordCount = count }, })) ``` **`ComarkParsePostState `:** | Field | Type | Description | | ---------- | --------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | | `markdown` | `string` | The original markdown input | | `tree` | [`MarkdownDocument `](https://comark.dev/getting-started/document-model#document-structure) | The parsed document; modify it to transform output. `tree.meta` / `tree.frontmatter` are typed against the plugin's declared contribution. | | `options` | [`ParserOptions`](https://comark.dev/reference/parse#options) | The parser configuration | | `tokens` | `unknown[]` | The raw markdown-it tokens | ::tip{to="https://comark.dev/plugins/custom/ast-api"} To traverse and transform `tree` nodes, see the AST API page for the `visit()` utility. :: --- ## `ComarkPlugin ` | Property | Type | Description | | ------------------- | ----------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `name` | `string` | A unique identifier for the plugin | | `markdownItPlugins` | [`MarkdownItPlugin[]`](https://markdown-it.github.io/markdown-it/#MarkdownIt) | markdown-it plugins to register on the parser, see [Markdown-it Plugins](https://comark.dev/plugins/custom/markdown-it) | | `pre` | `(state: ComarkParsePreState) => Promise \| void` | Hook called before parsing | | `post` | `(state: ComarkParsePostState ) => Promise \| void` | Hook called after the AST is built. `state.tree.meta` / `state.tree.frontmatter` are typed as the declared contribution, see [Typed contributions](#typed-contributions). | --- ## Usage Pass plugins to `parseMarkdown()` or the ` ` component: ::code-group ```typescript [Parse API] import { parseMarkdown } from 'comark' import myPlugin from './my-plugin' const tree = await parseMarkdown(content, { plugins: [myPlugin()] }) ``` ```vue [Vue] {{ content }} ``` ```tsx [React] import { Markdown } from '@comark/react' import myPlugin from './my-plugin'{content} ``` ```svelte [Svelte]``` :: --- - [Parse API](https://comark.dev/reference/parse) - [Document Model](https://comark.dev/getting-started/document-model) # AST API > Traverse and modify the MarkdownDocument AST after parsing using the visit() utility. The `comark/utils` module exports `visit`, a tree traversal utility for transforming AST nodes inside a [`post` hook](https://comark.dev/plugins/custom/plugin-api#lifecycle). ## `visit(tree, checker, visitor)`{lang="ts"} Traverses all nodes in a `MarkdownDocument`, calling `visitor` on every node that passes `checker`. **Parameters:** - `tree` - The [`MarkdownDocument`](https://comark.dev/getting-started/document-model#document-structure) to traverse - `checker` - A predicate `(node: Node) => boolean`, return `true` to visit a node - `visitor` - A transform `(node: Node) => Node | false | void`, see [`Node`](https://comark.dev/getting-started/document-model#node-model) **Returns:** `void` (mutations are applied in place) The visitor return value controls what happens to the node: | Return value | Effect | | ------------ | ---------------------------------------- | | `void` | Leave the node unchanged | | `Node` | Replace the node with the returned value | | `false` | Remove the node from the tree | **Example:** ```typescript import { defineComarkPlugin } from 'comark/parse' import { visit } from 'comark/utils' export default defineComarkPlugin(() => ({ name: 'word-count', post(state) { let count = 0 visit(state.tree, (node) => typeof node === 'string', (node) => { count += (node as string).split(/\s+/).filter(Boolean).length } ) state.tree.meta.wordCount = count }, })) ``` --- ## Use cases ### Replacing nodes Return a new node to replace the matched one. The following example wraps bare URL text nodes in anchor elements: ```typescript import type { Node } from 'comark' import { defineComarkPlugin } from 'comark/parse' import { visit } from 'comark/utils' export default defineComarkPlugin(() => ({ name: 'auto-link', post(state) { const urlPattern = /https?:\/\/[^\s]+/g visit(state.tree, (node) => typeof node === 'string', (node) => { const text = node as string if (!urlPattern.test(text)) return return ['a', { href: text }, text] as Node } ) }, })) ``` ### Mutating attributes Return `void` and mutate the node in place when you only need to update attributes: ```typescript import { defineComarkPlugin } from 'comark/parse' import { visit } from 'comark/utils' export default defineComarkPlugin(() => ({ name: 'styled-tables', post(state) { visit(state.tree, (node) => Array.isArray(node) && node[0] === 'table', (node) => { const el = node as [string, Record , ...any[]] el[1].class = [el[1].class, 'styled-table'].filter(Boolean).join(' ') } ) }, })) ``` ### Removing nodes Return `false` to remove matched nodes entirely: ```typescript import { defineComarkPlugin } from 'comark/parse' import { visit } from 'comark/utils' export default defineComarkPlugin(() => ({ name: 'strip-html-comments', post(state) { visit(state.tree, (node) => Array.isArray(node) && node[0] === null, () => false ) }, })) ``` --- - [Document Model](https://comark.dev/getting-started/document-model) - [Plugin API](https://comark.dev/plugins/custom/plugin-api) # Markdown-it > Use existing markdown-it plugins with Comark and create new parser syntax using markdown-it rules. Comark's default parser uses [markdown-exit](https://github.com/serkodev/markdown-exit), a TypeScript rewrite of [markdown-it](https://github.com/markdown-it/markdown-it) that preserves its plugin API. Most markdown-it plugins work with Comark with minimal effort. ## Usage Wrap any markdown-it plugin with [`defineComarkPlugin`](https://comark.dev/plugins/custom/plugin-api) and pass it in `markdownItPlugins`: ```typescript import { defineComarkPlugin } from 'comark/parse' import markdownItSub from 'markdown-it-sub' import markdownItSup from 'markdown-it-sup' const subscript = defineComarkPlugin(() => ({ name: 'subscript', markdownItPlugins: [markdownItSub], })) const superscript = defineComarkPlugin(() => ({ name: 'superscript', markdownItPlugins: [markdownItSup], })) ``` ::code-group ```typescript [Parse API] import { parseMarkdown } from 'comark' const tree = await parseMarkdown('H~2~O is water, 2^10^ is 1024', { plugins: [subscript(), superscript()] }) ``` ```vue [Vue] H~2~O is water ``` :: ## Create To add new syntax, write a markdown-it inline or block rule and include it in `markdownItPlugins`. Comark automatically converts the resulting tokens into AST nodes. The following example adds `==highlighted text==` syntax: ```typescript import type { MarkdownItPlugin } from 'comark' import { defineComarkPlugin } from 'comark/parse' const highlightRule = (state: any, silent: boolean) => { const start = state.pos const max = state.posMax if (start + 1 >= max) return false if (state.src.charCodeAt(start) !== 0x3D /* = */) return false if (state.src.charCodeAt(start + 1) !== 0x3D /* = */) return false let pos = start + 2 while (pos + 1 < max) { if (state.src.charCodeAt(pos) === 0x3D && state.src.charCodeAt(pos + 1) === 0x3D) { if (!silent) { const token = state.push('mark_open', 'mark', 1) token.markup = '==' state.pos = start + 2 state.posMax = pos state.md.inline.tokenize(state) state.push('mark_close', 'mark', -1) state.posMax = max } state.pos = pos + 2 return true } pos++ } return false } const markdownItHighlight: MarkdownItPlugin = (md) => { md.inline.ruler.before('emphasis', 'mark', highlightRule) } export default defineComarkPlugin(() => ({ name: 'highlight', markdownItPlugins: [markdownItHighlight], })) ``` `==hello==` becomes the following AST node: ```json ["mark", {}, "hello"] ``` ## Compatibility When you provide `markdownItPlugins`, they are registered directly on the underlying markdown-it instance with full access to its API: - **Inline rules**: [`md.inline.ruler`](https://markdown-it.github.io/markdown-it/#Ruler) - **Block rules**: [`md.block.ruler`](https://markdown-it.github.io/markdown-it/#Ruler) - **Core rules**: [`md.core.ruler`](https://markdown-it.github.io/markdown-it/#Ruler) - **Renderer rules**: [`md.renderer.rules`](https://markdown-it.github.io/markdown-it/#Renderer) (limited, see below) ### What works Most plugins that add **parsing rules** work out of the box: - Plugins that add new inline syntax (subscript, superscript, mark, insert) - Plugins that add new block syntax (containers, definition lists, footnotes) - Plugins that transform tokens via core rules (abbreviations, replacements) ### What doesn't work ::warning Plugins that rely on markdown-it's **renderer** will not work as expected. Comark uses its own rendering pipeline instead of `md.renderer`, so any logic in `md.renderer.rules` is ignored. :: If a plugin only customizes rendering, handle the rendering side in Comark using custom components: ::code-group ```vue [Vue]==highlighted text== ``` ```tsx [React] import { Markdown } from '@comark/react' const components = { mark: ({ children, ...props }) => {children} }{content} ``` :: ### markdown-exit vs markdown-it Comark uses [`markdown-exit`](https://github.com/serkodev/markdown-exit) as its base parser. This TypeScript rewrite preserves the markdown-it plugin API, so plugins written for markdown-it work without modification. The [`MarkdownItPlugin`](https://markdown-it.github.io/markdown-it/#MarkdownIt) type accepted by Comark is the standard plugin signature: ```typescript type MarkdownItPlugin = (md: MarkdownIt) => void ``` --- - [Plugin API](https://comark.dev/plugins/custom/plugin-api) - [Parse API](https://comark.dev/reference/parse) # Render API > Convert a Markdown AST back to markdown, preserving frontmatter and component syntax. ## `renderMarkdown(document, options?)`{lang="ts"} Converts a `MarkdownDocument` back into a markdown string, preserving frontmatter, component syntax, and attributes. **Parameters:** - `document` - The `MarkdownDocument` returned by `parseMarkdown()` or `createMarkdownParser()` - `options?` - Optional render options **Returns:** `Promise` (the serialized markdown string) **Example:** ::code-group ~~~typescript [render.ts] import { parseMarkdown } from 'comark' import { renderMarkdown } from 'comark/render' const document = await parseMarkdown(`--- title: Hello --- # Hello **World** ::alert{type="info"} This is an alert :: `) const markdown = await renderMarkdown(document) ~~~ ```markdown [Output] --- title: Hello --- # Hello **World** ::alert{type="info"} This is an alert :: ``` :: ::tip For the complete parsed document and node types, see the [Document Model](https://comark.dev/getting-started/document-model#node-model). :: --- ## Options | Option | Type | Default | Description | | ----------------------------------------------------- | ------------------------------------------------------- | ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | [`maxInlineAttributes`](#options-maxinlineattributes) | `number` | `3` | Maximum attributes before switching to YAML block syntax. Set to `0` to always use block syntax | | `blockAttributesStyle` | `'frontmatter' \| 'codeblock'` | `'codeblock'` | Syntax style for block attributes when they exceed `maxInlineAttributes` | | [`frontmatterOptions`](#options-frontmatteroptions) | `DumpOptions` | `{ indent: 2, lineWidth: -1 }` | js-yaml [DumpOptions](https://github.com/nodeca/js-yaml#dump-object---options-) passed to the frontmatter serializer | | `headingIds` | `boolean \| 'nested' \| 'flat'` | `true` | The [`headingIds`](https://comark.dev/reference/parse#options) mode the document was parsed with. A heading id equal to the auto-generated one is omitted; any other id is written as `{#id}` | | [`components`](#options-components) | `Record ` | `{}` | Custom render handlers for specific elements | | [`data`](#options-data) | `Record ` | `{}` | Additional data passed to render handlers | ### `maxInlineAttributes` When a component has more attributes than `maxInlineAttributes`, Comark switches to YAML block syntax. The `blockAttributesStyle` option controls which block format is used: ::code-group ```typescript [maxInlineAttributes: 3 (default)] // Renders as inline when 3 or fewer attributes // ::card{title="Hello" icon="star" color="blue"} // Switches to block syntax when more than 3 ``` ~~~mdc [codeblock style (default)] ::card ```yaml [props] title: Hello icon: star color: blue size: large ``` :: ~~~ ```mdc [frontmatter style] ::card --- title: Hello icon: star color: blue size: large --- :: ``` :: Switch between styles with the `blockAttributesStyle` option: ```typescript // Use codeblock style (default) const md = await renderMarkdown(document, { blockAttributesStyle: 'codeblock' }) // Use frontmatter style const md = await renderMarkdown(document, { blockAttributesStyle: 'frontmatter' }) // Always use block syntax with frontmatter style const md = await renderMarkdown(document, { maxInlineAttributes: 0, blockAttributesStyle: 'frontmatter', }) ``` ### `frontmatterOptions` By default frontmatter strings are never line-wrapped (`lineWidth: -1`). Pass any [js-yaml DumpOptions](https://github.com/nodeca/js-yaml#dump-object---options-) via `frontmatterOptions` to override this or control other serialization behaviour: ```typescript const md = await renderMarkdown(document, { frontmatterOptions: { lineWidth: 80, sortKeys: true }, }) ``` ### `components` Override how specific elements are serialized back to markdown. Each handler receives the node and a context object with a `render` helper for recursing into children. ```typescript import { parseMarkdown } from 'comark' import { renderMarkdown } from 'comark/render' const document = await parseMarkdown(source) const markdown = await renderMarkdown(document, { components: { alert: async ([, attrs, ...children], { render }) => { return `::alert{type="${attrs.type}"}\n${await render(children)}\n::` } } }) ``` #### Conditional handlers When a handler should apply based on a node's attributes rather than its tag name, use a `ConditionalNodeHandler`: ```typescript const markdown = await renderMarkdown(document, { components: { // The key is arbitrary; matching is done by the `match` function infoAlert: { match: (node) => node[0] === 'alert' && node[1].type === 'info', handler: async ([, , ...children], { render }) => { return `> **Info:** ${(await render(children)).trim()}\n` }, }, }, }) ``` Conditional handlers are checked after direct tag-based handlers. The first match wins. Some plugins export pre-built conditional handlers. For example, the [footnotes plugin](https://comark.dev/plugins/built-in/footnotes#stringify-markdown-rendering) provides `Footnote`: ```typescript import footnotes, { Footnote } from 'comark/plugins/footnotes' const document = await parseMarkdown(md, { plugins: [footnotes()] }) const markdown = await renderMarkdown(document, { components: { footnotes: Footnote }, }) ``` ### `data` Additional data passed to every component handler via the context object. Useful for sharing configuration or state across handlers: ```typescript const markdown = await renderMarkdown(document, { data: { baseUrl: 'https://example.com' }, components: { a: async ([, attrs, ...children], { render, data }) => { const href = attrs.href?.startsWith('/') ? `${data.baseUrl}${attrs.href}` : attrs.href return `[${await render(children)}](${href})` } } }) ``` --- ## Use cases ### Round-trip transformation Parse markdown, programmatically transform the AST, then serialize back: ```typescript import { parseMarkdown } from 'comark' import { renderMarkdown } from 'comark/render' import { visit } from 'comark/utils' const document = await parseMarkdown(source) // Add an "external" class to all links visit(document, (node) => Array.isArray(node) && node[0] === 'a', (node) => { const el = node as [string, Record , ...any[]] if (el[1].href?.startsWith('http')) { el[1].target = '_blank' } } ) const output = await renderMarkdown(document) ``` ### Content migration Convert between attribute styles or normalize formatting: ```typescript import { parseMarkdown } from 'comark' import { renderMarkdown } from 'comark/render' async function normalizeDocument(source: string) { const document = await parseMarkdown(source) // Re-serialize with consistent formatting using codeblock style return renderMarkdown(document, { maxInlineAttributes: 0 }) } // Or migrate to frontmatter style async function migrateToFrontmatter(source: string) { const document = await parseMarkdown(source) return renderMarkdown(document, { maxInlineAttributes: 0, blockAttributesStyle: 'frontmatter', }) } ``` ### Programmatic document generation Build a Comark document from data: ```typescript import { renderMarkdown } from 'comark/render' import type { MarkdownDocument } from 'comark' const document: MarkdownDocument = { frontmatter: { title: 'API Reference', date: '2025-01-01' }, meta: {}, nodes: [ ['h1', {}, 'API Reference'], ['p', {}, 'Welcome to the API docs.'], ['alert', { type: 'info' }, 'This API is in beta.'], ], } const markdown = await renderMarkdown(document) ``` --- --- - [Document Model](https://comark.dev/getting-started/document-model) - [Parse API](https://comark.dev/reference/parse) # Parse API > Parse Markdown into a compact, serializable document with parseMarkdown() or createMarkdownParser(), on the server, in the browser, or from a stream. ## `parseMarkdown(source, options?)`{lang="ts"} Parses Markdown from a string and returns a complete `MarkdownDocument`. Default plugins add frontmatter, alerts, task lists, HTML, components, and attributes to the standard Markdown parser. **Parameters:** - `source` - The Markdown content as a string - `options?` - Parser options including plugins **Returns:** `MarkdownDocument` object containing: - `nodes` - The parsed Markdown AST nodes - `frontmatter` - Frontmatter data parsed from YAML - `meta` - Additional metadata from plugins (for example, `toc`, `summary`) **Example:** ::code-group ~~~typescript [parse.ts] import { parseMarkdown } from 'comark' const content = `--- title: Hello World --- This is a simple example ` const result = await parseMarkdown(content) console.log(result) ~~~ ```json [Output] { "nodes": [ ["p", {}, "This is a simple example"] ], "frontmatter": { "title": "Hello World" }, "meta": {} } ``` :: ::tip For the complete parsed document and node types, see the [Document Model](https://comark.dev/getting-started/document-model#node-model). :: ### Frontmatter The parse function automatically extracts and parses YAML frontmatter: ::code-group ~~~typescript [parse.ts] const content = `--- title: My Document tags: - javascript - markdown author: name: John Doe email: john@example.com --- # Content here ` const result = await parseMarkdown(content) console.log(result.frontmatter) ~~~ ```json [Output] { "title": "My Document", "tags": ["javascript", "markdown"], "author": { "name": "John Doe", "email": "john@example.com" } } ``` :: ### Table of contents Register the [toc plugin](https://comark.dev/plugins/built-in/toc) to generate a table of contents based on headings: ::code-group ~~~typescript [parse.ts] import toc from 'comark/plugins/toc' const content = `# Main Title ## Section 1 Some content here. ### Subsection 1.1 More content. ## Section 2 Final content. ` const result = await parseMarkdown(content, { plugins: [toc()] }) console.log(result.meta.toc) ~~~ ```json [Output] { "title": "Main Title", "depth": 2, "searchDepth": 2, "links": [ { "id": "section-1", "text": "Section 1", "depth": 2, "children": [ { "id": "subsection-11", "text": "Subsection 1.1", "depth": 3 } ]}, { "id": "section-2", "text": "Section 2", "depth": 2 } ] } ``` :: ### HTML parsing HTML tags embedded in Comark content are parsed into AST nodes by default and can be mixed freely with Comark components and markdown syntax. ::code-group ~~~typescript [parse.ts] const content = ` ::alert{type="info"} Hello world ::` const result = await parseMarkdown(content) console.log(result.nodes) ~~~ ```json [Output] [ ["div", { "class": "note" }, ["alert", { "type": "info" }, "Hello ", ["strong", { "class": "text-red-500" }, "world"] ] ] ] ``` :: HTML parsing is provided by the built-in [`html` plugin](https://comark.dev/plugins/defaults/html). ### Summary ::tip Summary extraction requires the [summary plugin](https://comark.dev/plugins/built-in/summary). :: Content before the `` comment is extracted as a summary when using the summary plugin: ~~~typescript [parse.ts] import { parseMarkdown } from 'comark' import summary from 'comark/plugins/summary' const content = `# Article Title This is the introduction paragraph that will be used as a summary. This is the full article content that won't appear in the summary. ` const result = await parseMarkdown(content, { plugins: [summary()] }) console.log(result.meta.summary) // Node[] with only the content before ~~~ --- ## `createMarkdownParser(options?)`{lang="ts"} Creates a reusable parser function with pre-configured options. Unlike `parseMarkdown()` which creates a new parser instance on each call, `createMarkdownParser()` returns a parser function that can be called multiple times with the same configuration. **Parameters:** - `options?` - Parser options (same as `parseMarkdown()`) **Returns:** An async parser function `(source: string) => Promise` **Example:** ```typescript [parse.ts] import { createMarkdownParser } from 'comark' import shiki from 'comark/plugins/shiki' import githubLight from '@shikijs/themes/github-light' import githubDark from '@shikijs/themes/github-dark' import emoji from 'comark/plugins/emoji' import toc from 'comark/plugins/toc' // Create a parser with specific configuration const parse = createMarkdownParser({ autoUnwrap: true, autoClose: true, plugins: [ shiki({ themes: { light: githubLight, dark: githubDark } }), emoji(), toc() ] }) // Reuse the parser for multiple documents const doc1 = await parse('# Document 1\n\nContent...') const doc2 = await parse('# Document 2\n\nMore content...') const doc3 = await parse('# Document 3\n\nEven more...') ``` ### Use cases Here are some use cases for `createMarkdownParser()`: #### Static site generator Use `Promise.all` to parse all files in parallel. Since `createMarkdownParser()` initializes the parser once, the returned function is safe to call concurrently. ```typescript [build.ts] import { createMarkdownParser } from 'comark' import { readdir, readFile, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { renderHtmlFromDocument } from '@comark/html' import shiki from 'comark/plugins/shiki' import githubLight from '@shikijs/themes/github-light' import githubDark from '@shikijs/themes/github-dark' import toc from 'comark/plugins/toc' import emoji from 'comark/plugins/emoji' async function buildSite(contentDir: string, outDir: string) { // Create parser once with all desired plugins const parse = createMarkdownParser({ plugins: [ shiki({ themes: { light: githubLight, dark: githubDark } }), toc({ depth: 3 }), emoji() ] }) const files = await readdir(contentDir) const mdFiles = files.filter(f => f.endsWith('.md')) // Parse all files in parallel with the same parser instance await Promise.all( mdFiles.map(async (file) => { const content = await readFile(join(contentDir, file), 'utf-8') const doc = await parse(content) const html = await renderHtmlFromDocument(doc) await writeFile(join(outDir, file.replace('.md', '.html')), html) }) ) console.log(`Built ${mdFiles.length} pages`) } await buildSite('./content', './dist') ``` #### API server ```typescript [server.ts] import { createMarkdownParser } from 'comark' import security from 'comark/plugins/security' // Create parser once when server starts const parse = createMarkdownParser({ plugins: [ security() // Sanitize user-generated content ] }) // Reuse parser for every request app.post('/api/markdown', async (req, res) => { try { const tree = await parse(req.body.content) res.json({ success: true, tree }) } catch (error) { res.status(400).json({ error: 'Invalid markdown' }) } }) ``` ### Benchmark Using `createMarkdownParser()` has several benefits over calling `parseMarkdown()` multiple times: - **Performance**: Parser and plugins are initialized once, not on every parse - **Consistency**: All documents parsed with the same configuration - **Memory efficiency**: Single parser instance handles multiple documents - **Ideal for batch processing**: Perfect when parsing many files ```typescript [benchmark.ts] import { parseMarkdown, createMarkdownParser } from 'comark' import shiki from 'comark/plugins/shiki' const content = '```js\nconsole.log("hello")\n```' // ❌ Slow: Creates new parser + highlighter for each parse console.time('parse x1000') for (let i = 0; i < 1000; i++) { await parseMarkdown(content, { plugins: [shiki()] }) } console.timeEnd('parse x1000') // → ~8000ms (parser + highlighter recreated 1000 times) // ✅ Fast: Reuses same parser + highlighter instance console.time('createMarkdownParser x1000') const parse = createMarkdownParser({ plugins: [shiki()] }) for (let i = 0; i < 1000; i++) { await parse(content) } console.timeEnd('createMarkdownParser x1000') // → ~800ms (10x faster! parser + highlighter created once) ``` --- ## Options Both `parseMarkdown()` and `createMarkdownParser()` accept the same `ParserOptions`: | Option | Type | Default | Description | | ------------------------ | ----------------------------------------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `autoUnwrap` | `boolean` | `true` | Remove unnecessary ` ` wrappers from single-element containers | | `autoClose` | `boolean \| (markdown: string) => string` | `true` | Auto-close incomplete markdown syntax, or use a custom completion function | | `unwrap` | `boolean \| string \| string[]` | `false` | Remove wrapper tags from the tree, hoisting their children (MDC `unwrap` behaviour). `true` unwraps `p`; a comma/whitespace-separated string or array unwraps the listed tags; `'*'` matches any tag. Tags apply sequentially (each descends one level), and adjacent text is merged into a single string. | | `html` | `boolean` | `true` | **Deprecated** (warns). Prefer `registerDefaultPlugins: false` and register `html()` explicitly. `html: false` still skips the default html plugin. | | `linkify` | `boolean` | `true` | Auto-convert URL-like text into links. Set `false` to disable | | `headingIds` | `boolean \| 'nested' \| 'flat'` | `true` | Auto-generate `id` attributes for `h1`–`h6` from visible heading text (not tag or component names). `true`/`'flat'` uses the heading's own text only (`install`); `'nested'` prefixes each id with its parent heading's id (`setup-install`); `false` disables auto ids. Unicode letters, marks, and numbers are kept (`## Café` → `café`); punctuation and symbols are dropped, and a heading that slugifies to nothing gets no id. Ids are unique per document: duplicates get a `-1`, `-2`, ... suffix (`options`, `options-1`, `options-2`). An explicit `id` attribute wins. See [Headings](https://comark.dev/syntax/markdown#headings). | | `registerDefaultPlugins` | `boolean` | `true` | Register the built-in default plugins (`frontmatter`, `html`, `alert`, `task-list`, `components`, `attributes`). Set `false` to disable them. | | `plugins` | `ComarkPlugin[]` | `[]` | Ordered plugins to run after the defaults. A same-name plugin replaces its default; duplicate explicit names keep the first instance. See [Default plugins](https://comark.dev/plugins#default-plugins). | | `tracer` | `ComarkTracer` | `undefined` | Timing recorder for the parse pipeline — see [Timing the parse](#timing-the-parse) | ### Timing the parse Pass a `tracer` to time each phase of the pipeline and every plugin hook, so you can see where parse time goes (for example, a slow `post` shiki hook). The contract is a structural subset of [OpenTelemetry](https://opentelemetry.io/docs/languages/js/instrumentation/#creating-spans) `Tracer` — `startSpan` and `startActiveSpan` — so a real OTel tracer works as-is: ```ts import { trace } from '@opentelemetry/api' import shiki from 'comark/plugins/shiki' const parse = createMarkdownParser({ // Uses the OpenTelemetry provider registered by your app or hosting platform. tracer: trace.getTracer('comark'), plugins: [shiki()], }) ``` ::note `@opentelemetry/api` defines the tracing API but does not export spans by itself. `trace.getTracer()` uses the globally registered OpenTelemetry provider; without one, it returns a no-op tracer. Hosting platforms often register and configure that provider for you. Otherwise, initialize an OpenTelemetry SDK and exporter before creating the parser. :: See the [complete Node.js example](https://github.com/comarkdown/comark/blob/main/examples/3.cli/perf-trace/otel.ts), including an OTLP/HTTP exporter and commands for viewing traces locally with `otel-front`. Or a minimal recorder: ```ts const spans: { name: string, duration: number }[] = [] const tracer = { startSpan(name) { const start = performance.now() return { end: () => spans.push({ name, duration: performance.now() - start }) } }, startActiveSpan(name, optionsOrFn, maybeFn) { const fn = typeof optionsOrFn === 'function' ? optionsOrFn : maybeFn const span = tracer.startSpan(name) // Nested startActiveSpan/startSpan calls become children via your context/stack. return fn(span) // caller (comark) ends the span }, } const parse = createMarkdownParser({ tracer, plugins: [shiki()] }) await parse(markdown) // spans → comark:parse // ├─ comark:autoclose // ├─ comark:pre:frontmatter // ├─ comark:tokenize // ├─ comark:nodes // ├─ comark:post:alert // └─ comark:post:shiki // … ``` Recorded spans: a root `comark:parse` active span enclosing `comark:autoclose`, `comark:tokenize` (markdown parsing), `comark:nodes` (token → AST conversion and unwrapping), and `comark:pre:
` / `comark:post: ` for each plugin hook. Nested `startActiveSpan` calls form the parent → child hierarchy (OTel active context, or a stack in a simple recorder). There is no timing overhead when `tracer` is omitted, and no Node-specific API is used — it works in the browser too. ### Inline rendering Use `unwrap: 'p'` (or `unwrap: true`) to render markdown without the wrapping ` `, which is handy for buttons, badges, and other inline hosts. Adjacent text is merged into a single string, matching MDC's `unwrap`: ```ts await parseMarkdown('Hello **world**', { unwrap: 'p' }) // nodes: ['Hello ', ['strong', {}, 'world']] await parseMarkdown('a\n\nb', { unwrap: true }) // nodes: ['ab'] (paragraphs merged, no separator) ``` Tags are applied **sequentially**, each descending one level into the result of the previous one — so a space-separated (or comma-separated) list peels nested wrappers. `'*'` matches any tag: ```ts // Unwrap the
, then the
- inside it await parseMarkdown('- Buy milk', { unwrap: 'ul li' }) // nodes: ['Buy milk'] ``` On the framework components this is exposed as an `unwrap` shorthand prop: ```vue
``` --- --- - [Document Model](https://comark.dev/getting-started/document-model) - [Render API](https://comark.dev/reference/render) # Streaming Markdown: Auto-Close Incomplete LLM Output > Render incomplete Markdown correctly during streaming. autoCloseMarkdown() closes unterminated syntax so AI output displays at every frame. A language model streams Markdown a few characters at a time, so most frames end inside a bold span, a code fence, or a component. `autoCloseMarkdown()` closes the syntax that is still open, so every frame renders as finished markup. `parseMarkdown()` and every renderer call it by default (the `autoClose` option), so you only call it yourself on raw Markdown strings. ## `autoCloseMarkdown(source, options?)`{lang="ts"} Automatically closes unclosed markdown inline syntax and Comark components. Built for streaming scenarios where content arrives incrementally and may be incomplete at any point. CommonMark/GFM healing follows the behavioral SPEC in `packages/comark/SPEC/auto-close.md`. **Parameters:** - `source` - The markdown content (potentially partial/incomplete) - `options` (optional) - Feature flags and Comark settings: - **Comark:** `frontmatter: true` completes an unclosed leading frontmatter block; `syntax: false` skips component-fence closing; `attributes` controls `{...}` attribute scopes (defaults to `syntax`) - **Links / images:** `linkMode: 'protocol' | 'text-only'` (default `'protocol'`), `incompleteLinkPlaceholder` (default `comark:incomplete-link`), `incompleteImagePlaceholder` (default `comark:incomplete-image`) - **Math:** inline `$…$` and block `$$…$$` off by default on bare `autoCloseMarkdown`; pass `math: true` (or use `parseMarkdown`, which enables it) - **Streaming:** `dropTrailingOpeners: true` drops a trailing opener after whitespace (`hello *` → `hello`) so half-typed markers do not flash. Enabled automatically when parsing with `streaming: true`. **Returns:** `string`, the source with all unclosed syntax closed **Example:** ::code-group ```typescript [auto-close.ts] import { autoCloseMarkdown } from 'comark' autoCloseMarkdown('**bold text') // '**bold text**' autoCloseMarkdown('[partial link') // '[partial link](comark:incomplete-link)' autoCloseMarkdown('$x = 5') // '$x = 5' autoCloseMarkdown('$x = 5', { math: true }) // '$x = 5$' autoCloseMarkdown('hello *', { dropTrailingOpeners: true }) // 'hello' ``` ```typescript [Output] '**bold text**' ``` :: ::tip `autoCloseMarkdown` is also available as a parse option: set `autoClose: true` (default) in `parseMarkdown()` or `createMarkdownParser()` to apply it automatically. :: ### Parser integration `autoClose` is enabled by default in `parseMarkdown()` and `createMarkdownParser()`. You can disable it or provide a custom completion function: ::code-group ```typescript [Enabled (default)] import { parseMarkdown } from 'comark' const result = await parseMarkdown(content, { autoClose: true // default }) ``` ```typescript [Disabled] import { parseMarkdown } from 'comark' const result = await parseMarkdown(content, { autoClose: false }) ``` ```typescript [Manual] import { autoCloseMarkdown, parseMarkdown } from 'comark' const closed = autoCloseMarkdown(content) const result = await parseMarkdown(closed, { autoClose: false }) ``` ```typescript [Custom] import { createMarkdownParser } from 'comark' const parse = createMarkdownParser({ autoClose: markdown => completeMarkdown(markdown) }) ``` :: During incremental parsing with `{ streaming: true }`, the custom function receives only the unstable tail that Comark still needs to parse. On a regular parse, it receives the full input. The function must return the markdown string to tokenize. ### Supported syntax #### Inline Markdown | Syntax | Example | Auto-closed | | ---------------- | ----------------- | --------------------------------- | | Bold | `**text` | `**text**` | | Italic | `*text` / `_text` | `*text*` / `_text_` | | Bold-italic | `***text` | `***text***` | | Code | ```code`` | ```code``` | | Strikethrough | `~~text` | `~~text~~` | | Link (protocol) | `[text](url` | `[text](comark:incomplete-link)` | | Link (text-only) | `[text` | `text` | | Image | `` | | Block math | `$$x` | `$$x$$` | | Inline math | `$x` | `$x$` (`math: true`) | #### Comark components Block components are closed based on their marker count: ```typescript [auto-close.ts] // Double marker (block component) autoCloseMarkdown('::alert\nContent') // '::alert\nContent\n::' // Triple marker (nested component) autoCloseMarkdown(':::card\nContent') // ':::card\nContent\n:::' // Nested components autoCloseMarkdown('::::outer\n:::inner\n::component') // '::::outer\n:::inner\n::component\n::\n:::\n::::' ``` #### Props and attributes Components with props are handled correctly: ```typescript [auto-close.ts] // Inline props autoCloseMarkdown('::alert{type="info" title="Note"}') // '::alert{type="info" title="Note"}\n::' // YAML props autoCloseMarkdown(`::component\n---\nkey: value\n---\nContent`) // '::component\n---\nkey: value\n---\nContent\n::' ``` --- ## Use cases ### AI streaming When streaming AI-generated markdown, content arrives in chunks and may be incomplete at any point: ```typescript [chat.ts] import { autoCloseMarkdown, parseMarkdown } from 'comark' let accumulated = '' socket.on('chunk', async (chunk) => { accumulated += chunk // Auto-close before parsing to ensure valid AST at every chunk const closed = autoCloseMarkdown(accumulated) const result = await parseMarkdown(closed) renderContent(result.nodes) }) socket.on('end', async () => { const result = await parseMarkdown(accumulated) renderFinalContent(result.nodes) }) ``` ### AI SDK Build a streaming AI chat with live Comark rendering in a few lines. The [` `](https://comark.dev/rendering/vue) component applies `autoCloseMarkdown` on every render by default, so the AST stays valid at every chunk and you get smooth incremental rendering without any extra wiring. ::steps{level="4"} #### Install dependencies ```bash npm install ai @ai-sdk/vue ``` #### Create a server route ```typescript [server/api/chat.post.ts] import { convertToModelMessages, streamText } from 'ai' export default defineEventHandler(async (event) => { const { messages } = await readBody(event) const result = streamText({ model: 'anthropic/claude-sonnet-4.6', system: 'You are a helpful assistant. Always respond using Comark syntax.', messages: await convertToModelMessages(messages), }) return result.toUIMessageStreamResponse() }) ``` #### Render on the client Use the `Chat` class from `@ai-sdk/vue` and the ` ` component to parse and render the content as it arrives. Use `isPartStreaming(part)` for per-part streaming detection: ```vue [app/pages/chat.vue] {{ part.text }}
``` :: ::callout{icon="i-lucide-square-play" color="info" to="https://github.com/comarkdown/comark/tree/main/examples/4.ai/nuxt-ai-sdk"} See the full working example with server route and chat UI wired together. :: The `caret` prop on ` ` appends a blinking cursor to the last text node while streaming. See the [Vue rendering docs](https://comark.dev/rendering/vue#streaming) for custom caret styling. ### Real-time editor Show a live preview while the user types: ```typescript [editor.ts] import { autoCloseMarkdown, parseMarkdown } from 'comark' import { renderHtmlFromDocument } from '@comark/html' editor.addEventListener('input', async (e) => { const closed = autoCloseMarkdown(e.target.value) const result = await parseMarkdown(closed) preview.innerHTML = await renderHtmlFromDocument(result) }) ``` ### Incremental file upload Parse content progressively as a file uploads: ```typescript [upload.ts] import { autoCloseMarkdown, parseMarkdown } from 'comark' async function uploadAndParse(file: File) { const chunkSize = 64 * 1024 // 64KB let offset = 0 let accumulated = '' while (offset < file.size) { const chunk = file.slice(offset, offset + chunkSize) accumulated += await chunk.text() const closed = autoCloseMarkdown(accumulated) const result = await parseMarkdown(closed) updateProgress({ percent: (offset / file.size) * 100, preview: result.nodes }) offset += chunkSize } return parseMarkdown(accumulated) } ``` ### Performance Call `autoCloseMarkdown` once per chunk on the accumulated content, not on every character: ```typescript [stream.ts] // ✅ Good: call once per chunk for await (const chunk of stream) { accumulated += chunk const closed = autoCloseMarkdown(accumulated) render(await parseMarkdown(closed)) } // ❌ Avoid: calling for every character for (const char of text) { accumulated += char const closed = autoCloseMarkdown(accumulated) // too frequent render(await parseMarkdown(closed)) } ``` --- --- - [Parse API](https://comark.dev/reference/parse) - [Render API](https://comark.dev/reference/render) # Cheat Sheet > Quick reference for all Comark exports, types, and APIs across all packages. ## `comark` (core) ### `parseMarkdown(source, options?)` Parse Comark content into a `MarkdownDocument`. ```typescript import { parseMarkdown } from 'comark' import shiki from 'comark/plugins/shiki' const result = await parseMarkdown(source, { autoUnwrap: true, // Remove wrappers from single-paragraph containers autoClose: true, // Auto-close incomplete syntax plugins: [shiki()] // HTML parsing is on by default via the html plugin }) result.nodes // Node[]: parsed AST nodes result.frontmatter // Record
: frontmatter data result.meta.toc // TOC object (from toc plugin) result.meta.summary // Node[] (from summary plugin) ``` ### `createMarkdownParser(options?)` Creates a reusable parser initialized once, more efficient for batch processing. ```typescript import { createMarkdownParser } from 'comark' const parse = createMarkdownParser({ plugins: [shiki()] }) const tree1 = await parse('# Document 1') const tree2 = await parse('# Document 2') ``` ### `renderMarkdown(document, options?)` Convert a `MarkdownDocument` back to a markdown string. ```typescript import { parseMarkdown } from 'comark' import { renderMarkdown } from 'comark/render' const document = await parseMarkdown(source) const markdown = await renderMarkdown(document, { maxInlineAttributes: 3, // Switch to YAML block syntax above this threshold }) ``` ### `autoCloseMarkdown(source)` Close unclosed markdown syntax and Comark components. Useful for streaming. ```typescript import { autoCloseMarkdown } from 'comark' autoCloseMarkdown('**bold') // '**bold**' autoCloseMarkdown('::alert\nText') // '::alert\nText\n::' ``` --- ## String renderers ### `@comark/html` #### `renderHtml(markdown, options?)` Parse and render markdown to an HTML string in one call. ```typescript import { renderHtml } from '@comark/html' const html = await renderHtml('# Hello\n\nThis is **bold**.') ``` #### `createHtmlRenderer(options?)` Creates a reusable parse+render function initialized once. ```typescript import { createHtmlRenderer } from '@comark/html' const renderHtml = createHtmlRenderer({ plugins: [shiki()], components: { alert: async ([, attrs, ...children], { render }) => ` ${await render(children)}` } }) const html = await renderHtml(source) ``` #### `renderHtmlFromDocument(document, options?)` Render a pre-parsed `MarkdownDocument` to an HTML string. ```typescript import { renderHtmlFromDocument } from '@comark/html' const html = await renderHtmlFromDocument(document, { components: { alert: async ([, attrs, ...children], { render }) => `${await render(children)}` } }) ``` ### `@comark/ansi` #### `renderAnsi(markdown, options?)` Parse and render markdown to an ANSI-styled terminal string in one call. ```typescript import { renderAnsi } from '@comark/ansi' const output = await renderAnsi('# Hello\n\nThis is **bold**.') process.stdout.write(output) ``` #### `createAnsiRenderer(options?)` Create a reusable parse-and-render function with pre-configured parser and ANSI renderer options. ```typescript import { createAnsiRenderer } from '@comark/ansi' const renderAnsi = createAnsiRenderer({ colors: false, width: 120 }) const output = await renderAnsi('# Hello') ``` #### `renderAnsiFromDocument(document, options?)` Render a pre-parsed `MarkdownDocument` to an ANSI-styled string. ```typescript import { parseMarkdown } from 'comark' import { renderAnsiFromDocument } from '@comark/ansi' const document = await parseMarkdown('# Hello') const output = await renderAnsiFromDocument(document) process.stdout.write(output) ``` #### `printAnsi(markdown, options?)` Parse and render markdown directly to stdout. Replaces the deprecated `writeAnsi` alias. ```typescript import { printAnsi } from '@comark/ansi' await printAnsi('# Hello\n\nThis is **bold**.') ``` #### `createAnsiPrinter(options?)` Create a reusable Markdown printer. Pass `writer` to target something other than `process.stdout`. Replaces the deprecated `createAnsiWriter` alias. ```typescript import { createAnsiPrinter } from '@comark/ansi' const printAnsi = createAnsiPrinter({ writer: (output) => process.stderr.write(output), }) await printAnsi('# Hello') ``` --- ## Framework renderers `@comark/vue` · `@comark/react` · `@comark/svelte` · `@comark/angular` · `@comark/nuxt`: same API, different import path. ### `` Parses and renders markdown in one async step. ::code-group ```vue [Vue] {{ content }} ``` ```tsx [React]{content} ``` ```svelte [Svelte]``` ```html [Angular] ``` :: ### ` ` Renders a pre-parsed `MarkdownDocument` with no parser shipped to the client. ::code-group ```vue [Vue] ``` ```tsx [React] ``` ```svelte [Svelte] ``` ```html [Angular] ``` :: ### `defineMarkdownComponent(options)` Creates a pre-configured ` ` with baked-in plugins and component mappings. ```typescript import { defineMarkdownComponent } from '@comark/vue' // or @comark/react, @comark/angular export const AppMarkdown = defineMarkdownComponent({ plugins: [shiki(), toc()], components: { alert: CustomAlert }, }) ``` --- ## Types ### `MarkdownDocument` ```typescript interface MarkdownDocument { nodes: Node[] frontmatter: Record meta: { toc?: Toc summary?: Node[] [key: string]: any } } ``` ### `RenderMarkdownOptions` ```typescript interface RenderMarkdownOptions { maxInlineAttributes?: number // Max inline attributes before switching to YAML block (default: 3) blockAttributesStyle?: 'frontmatter' | 'codeblock' // Block attribute syntax style (default: 'codeblock') frontmatterOptions?: DumpOptions // js-yaml options for frontmatter serialization components?: Record // Custom render handlers for specific elements data?: Record // Additional data passed to render handlers } ``` ### `Node` ```typescript type Node = | string // TextNode | [tag: string, attrs: ElementNodeAttributes, ...children: Node[]] // ElementNode | [tag: null, attrs: ElementNodeAttributes, comment: string] // CommentNode ``` ### `ParserOptions` ```typescript interface ParserOptions { autoUnwrap?: boolean // default: true autoClose?: boolean | AutoCloseFunction // default: true unwrap?: boolean | string | string[] // strip top-level wrapper tags, e.g. 'p' (default: false) /** @deprecated Prefer registerDefaultPlugins: false */ html?: boolean // default: true linkify?: boolean // default: true headingIds?: boolean | 'nested' | 'flat' // default: true ('flat') — slug from visible text; duplicates get -1, -2, … (keeps Unicode letters/numbers, omits symbol-only ids); explicit id wins registerDefaultPlugins?: boolean // default: true plugins?: ComarkPlugin[] tracer?: ComarkTracer // OpenTelemetry-style tracer for parse spans } ``` ### `Toc` ```typescript // Exported from 'comark/plugins/toc' interface Toc { title: string depth: number searchDepth: number links: TocLink[] } interface TocLink { id: string text: string depth: number children?: TocLink[] } ``` --- ## Imports ```typescript [imports.ts] // Core import { parseMarkdown, createMarkdownParser, autoCloseMarkdown } from 'comark' import { renderMarkdown } from 'comark/render' import type { RenderMarkdownOptions } from 'comark/render' import { defineComarkPlugin } from 'comark' // Plugins import shiki from 'comark/plugins/shiki' import emoji from 'comark/plugins/emoji' import toc from 'comark/plugins/toc' import summary from 'comark/plugins/summary' import security from 'comark/plugins/security' // HTML rendering import { createHtmlRenderer, renderHtml, renderHtmlFromDocument } from '@comark/html' // ANSI rendering import { renderAnsi, renderAnsiFromDocument, createAnsiRenderer, printAnsi, createAnsiPrinter, } from '@comark/ansi' // Vue import { Markdown, MarkdownDocument, defineMarkdownComponent } from '@comark/vue' // React import { Markdown, MarkdownDocument, defineMarkdownComponent } from '@comark/react' // Svelte import { Markdown, MarkdownDocument } from '@comark/svelte' // Types import type { MarkdownDocument, ElementNode, TextNode, CommentNode, ElementNodeAttributes, Node, ParserOptions, ComarkPlugin, } from 'comark' ``` ::tip{to="https://comark.dev/rendering/nuxt"} Using Nuxt? `Markdown` and `MarkdownDocument` are auto-imported, no import statements needed. :: --- ## Related - [Parse API](https://comark.dev/reference/parse) - Full parse options, plugins, and examples - [Render API](https://comark.dev/reference/render) - Render a `MarkdownDocument` back to markdown - [Streaming API](https://comark.dev/reference/auto-close) - Handle incomplete syntax for streaming - [HTML Rendering](https://comark.dev/rendering/html) - Server-side HTML generation - [Vue Rendering](https://comark.dev/rendering/vue) - Render in Vue applications - [React Rendering](https://comark.dev/rendering/react) - Render in React applications - [Svelte Rendering](https://comark.dev/rendering/svelte) - Render in Svelte applications - [Nuxt Rendering](https://comark.dev/rendering/nuxt) - Zero-config Nuxt module - [ANSI Rendering](https://comark.dev/rendering/ansi) - Terminal output - [Plugins](https://comark.dev/plugins) - Syntax highlighting, math, TOC, and more - [Document Model](https://comark.dev/getting-started/document-model) - Parsed document and node model --- - [Parse API](https://comark.dev/reference/parse) - [Render API](https://comark.dev/reference/render) # Why Comark? One Markdown Pipeline for Every Renderer > Comark gives JavaScript applications one Markdown pipeline for standards-based parsing, serializable documents, multiple renderers, plugins, and streaming. Markdown won the authoring war. It is readable, portable, widely supported, and understood by both humans and machines. The remaining challenge is everything that happens after authoring: parsing it reliably, transforming it as data, rendering it to different targets, and displaying it while content is still arriving. Comark is an open-source Markdown parser and renderer with components, attributes and plugins. It streams AI output to React, Vue, Svelte, Angular, HTML and the terminal, so one library covers that complete Markdown pipeline. ## Why Markdown? Every piece of web content serves two audiences: - **Humans** who need scannable prose - **Machines** who need predictable structure HTML is powerful but verbose. That verbosity costs even more in an era where AI agents pay per token. JSON is structured but no one wants to write a blog post in it. **Markdown occupies rare middle ground.** The source text is comfortable prose for a human author and token-efficient structured data for a machine consumer. That convergence wasn't part of [Gruber's original design](https://daringfireball.net/projects/markdown/). He was solving for readability in 2004. But it explains why Markdown has become the default format for both human authoring and AI consumption. ### Why machines prefer Markdown The evidence is clearest in token economics. The same page served as HTML versus Markdown to an AI agent can use **80% fewer tokens** in Markdown form. At scale, that is not a formatting preference, it is an infrastructure decision. Lower token counts mean faster responses, lower costs, and longer effective context windows. Beyond token count, Markdown's explicit structure maps cleanly to how language models reason. A `##` heading is unambiguously a section boundary. HTML carries the same information buried in ` ` but the signal-to-noise ratio is far worse. ### AI as a first-class audience The tooling industry has converged on this conclusion. [Streamdown](https://github.com/vercel/streamdown) (an open-source renderer by Hayden Bleasel, DX Engineer at Vercel) was built precisely because LLMs stream token by token and existing renderers like `react-markdown` assume a complete document, making them unable to handle unterminated blocks gracefully. Because LLMs tend to produce Markdown by default, Streamdown is a React component designed to render it correctly as chunks arrive, pairing naturally with `useChat` and other streaming patterns from `@ai-sdk/react`. Together they reflect a deliberate recognition that AI agents are now a *primary* consumer of content, not a secondary one ([Comark vs Streamdown](https://comark.dev/compare/comark-vs-streamdown)). The same shift is visible in developer tooling. Files like `AGENTS.md` and `.cursorrules` are Markdown documents that configure AI coding assistants. GitHub Copilot reads them across sessions. Cursor injects them into every prompt. Claude Code uses `CLAUDE.md`. The format that humans write is the same format that machines act on. This is new. For most of computing history, human-authored content and machine-readable configuration were different artifacts in different formats. Markdown is collapsing that boundary. ## Why Comark? The JavaScript ecosystem has excellent Markdown parsers and renderers, but most tools solve only one part of the pipeline. A renderer may be tied to one UI framework. A build-time compiler may not accept content from a database or live API. A conventional parser may assume the document is complete, which causes broken formatting during an AI stream. Comark separates these concerns. It parses Markdown into a shared document model, lets you transform or store that document, and renders it through the output package you choose. The same API works at build time or runtime, on the server or in the browser, and with complete or streaming input. ### Built on markdown-it foundations Comark supports CommonMark and GitHub Flavored Markdown (GFM) through [markdown-exit](https://github.com/serkodev/markdown-exit), a TypeScript rewrite of markdown-it that preserves its plugin API. Existing Markdown stays familiar, and existing markdown-it plugins can work alongside [Comark plugins](https://comark.dev/plugins). This foundation gives Comark a mature parsing model without limiting how or where the parsed document is rendered. ### Markdown as data, not code `parseMarkdown(markdown)` returns a compact, serializable [`MarkdownDocument`](https://comark.dev/getting-started/document-model). Text stays as strings, elements use tuples such as `['tag', props, ...children]`, and plugins can attach structured metadata. That shared document boundary enables a complete content pipeline: 1. A file, database, CMS, or API provides the raw Markdown 2. On request, the server runs `parseMarkdown(markdown)` and gets the [`MarkdownDocument`](https://comark.dev/getting-started/document-model) object 3. Your application inspects, transforms, caches, or sends the document as JSON 4. An HTML, ANSI, Vue, React, Svelte, or Angular renderer produces the final output You can parse once and render many times without coupling stored content to a framework or build pipeline. ::tip You can store the `MarkdownDocument` directly in the database to skip re-parsing on every request. :: ### One source, every renderer Most Markdown rendering libraries are designed around one output target. Comark decouples parsing from rendering so the same source can produce: - [`renderHtml()` in @comark/html](https://comark.dev/rendering/html): server-side HTML strings, RSS, emails, and static builds - [`renderAnsi()` in @comark/ansi](https://comark.dev/rendering/ansi): styled terminal output for CLIs - [`
` in Vue](https://comark.dev/rendering/vue): async components, streaming, and slot-based composition - [` ` in React](https://comark.dev/rendering/react): Server Components and client-side rendering - [` ` in Svelte](https://comark.dev/rendering/svelte): snippets and streaming - [` ` in Angular](https://comark.dev/rendering/angular): standalone components with signals and streaming Your content outlasts your output target and framework choice. ### Streaming is part of the parser AI models emit Markdown token by token, but conventional parsers expect complete documents. Comark's [auto-close](https://comark.dev/reference/auto-close) support completes unterminated emphasis, links, code fences, and component blocks so each intermediate frame renders correctly. Framework renderers expose streaming directly, while the core parser lets any integration opt into the same behavior. You can display content as soon as it arrives without maintaining a separate streaming syntax or renderer. ### Extend Markdown without embedding code Standard Markdown does not express a warning callout, tabbed code group, video embed, or pricing card. Raw HTML can represent them, but it adds verbosity and couples content to markup. MDX allows executable JSX, but it also makes content part of a framework-specific build pipeline ([full comparison](https://comark.dev/compare/comark-vs-mdx)). Comark keeps extensions in readable plain text. Its component syntax is inspired by the [Markdown directives proposal](https://talk.commonmark.org/t/generic-directives-plugins-syntax/444) and builds on more than five years of production use in [MDC](https://github.com/nuxt-content/mdc): ```mdc ::alert{type="warning"} This action cannot be undone. :: ``` A human can read the source, while the parser sees a component named `alert` with a `type` attribute and text content. The renderer only instantiates components registered by the application, so content remains data rather than executable code. Attributes provide the same plain-text approach for adding classes, IDs, styles, and metadata to standard Markdown elements. Both extensions are available when you need them; plain CommonMark and GFM continue to work unchanged. ### First-class slots Components can accept rich Markdown in named regions. `#slotname` markers let you compose full Markdown into each slot without falling back to HTML or framework templates: ```mdc ::hero #title Build UIs from **Markdown** #description Comark parses component syntax at runtime: no compiler, no rebuild. Renders to HTML, ANSI, Vue, React, Svelte, and Angular. #cta [Get started](/getting-started/installation) :: ``` This is still Markdown. Readable in a text editor, parseable by a machine, renderable by any framework. --- Markdown gives humans and machines a shared language. Comark gives that language one pipeline: parse once, render anywhere, and stream in real time. --- - [Comark Syntax](https://comark.dev/syntax/components) - [Installation](https://comark.dev/getting-started/installation) # Migrating from MDC > Step-by-step guide for moving from @nuxtjs/mdc to Comark, covering package mapping, API changes, component registration, and renderer setup. Comark is the successor to `@nuxtjs/mdc`. The markdown syntax is fully compatible: your `.md` files need no changes. What changes is the JavaScript API: package names, parse functions, the AST format, and how plugins work. ## Quick overview - **Package**: `@nuxtjs/mdc` → `comark` (core) or `@comark/nuxt` (Nuxt module) - **Parse**: `parseMarkdown()` → `parseMarkdown()` · factory: `createMarkdownParser()` (sync, no await) - **AST**: object tree → compact tuples `['tag', props, ...children]` - **Result**: `result.body` / `result.data` → `document.nodes` / `document.frontmatter` - **Renderer**: ` ` → ` ` - **All-in-one**: ` ` → ` ` - **Slots**: ` ` → native ` ` - **Plugins**: global `nuxt.config` → per-component `defineMarkdownComponent({ plugins })` - **Binding**: `{{ path }}` via opt-in [binding plugin](https://comark.dev/plugins/built-in/binding) + render-time `data` prop (namespaced paths) - **Markdown files**: component syntax unchanged; update binding paths if you used bare MDC names ## Agent skill To give coding agents the MDC-to-Comark migration skill, install it from production: ```bash [Terminal] npx skills add https://comark.dev/skills/migrate-mdc-to-comark ``` ## Package changes If you only use the programmatic API (parse functions, plugins, AST manipulation), install `comark` alone: ::code-group ```bash [npm] npm uninstall @nuxtjs/mdc npm install comark ``` ```bash [pnpm] pnpm remove @nuxtjs/mdc pnpm add comark ``` ```bash [yarn] yarn remove @nuxtjs/mdc yarn add comark ``` ```bash [bun] bun remove @nuxtjs/mdc bun add comark ``` :: If you also use the Nuxt module (renderer components, auto-imports, prose components), install `@comark/nuxt` instead, as it includes `comark` as a dependency: ::code-group ```bash [npm] npm uninstall @nuxtjs/mdc npm install @comark/nuxt ``` ```bash [pnpm] pnpm remove @nuxtjs/mdc pnpm add @comark/nuxt ``` ```bash [yarn] yarn remove @nuxtjs/mdc yarn add @comark/nuxt ``` ```bash [bun] bun remove @nuxtjs/mdc bun add @comark/nuxt ``` :: ## Core package The `comark` package is the foundation all other packages build on. The changes below apply to all programmatic usage, regardless of whether you use the Nuxt module. ### Parse functions #### One-shot parsing ::code-group ```typescript [Before] import { parseMarkdown } from '@nuxtjs/mdc/runtime' const result = await parseMarkdown(markdown) ``` ```typescript [After] import { parseMarkdown } from 'comark' const document = await parseMarkdown(markdown) ``` :: #### Reusable parser ::code-group ```typescript [Before] import { createMarkdownParser } from '@nuxtjs/mdc/runtime' const parser = await createMarkdownParser(options) const result = await parser(markdown) ``` ```typescript [After] import { createMarkdownParser } from 'comark' const parse = createMarkdownParser(options) // synchronous, no await needed const document = await parse(markdown) ``` :: Comark's `createMarkdownParser` is synchronous: the plugin system is eagerly initialized, so the factory returns immediately. MDC's factory was async because it had to initialize the unified processor pipeline. ::tip{to="https://comark.dev/reference/parse"} See the Parse API for the full list of options and return types. :: ### Render functions #### Serialize back to Markdown ::code-group ```typescript [Before] import { stringifyMarkdown } from '@nuxtjs/mdc/runtime' const markdown = stringifyMarkdown(result.body, result.data) ``` ```typescript [After] import { renderMarkdown } from 'comark/render' const markdown = await renderMarkdown(document) ``` :: `renderMarkdown` includes frontmatter automatically: it reads `document.frontmatter` and serializes it as YAML front matter. See the [Render API](https://comark.dev/reference/render) for full options. ### Return type The shape of the parsed result changed. ::code-group ```typescript [Before] // MDCParserResult { body: MDCRoot // the AST data: { // frontmatter title: string description: string [key: string]: any } toc: Toc | undefined excerpt: MDCRoot | undefined } ``` ```typescript [After] // MarkdownDocument { nodes: Node[] // the AST frontmatter: Record // frontmatter (all keys) meta: { toc?: Toc // requires toc plugin summary?: Node[] // requires summary plugin [key: string]: any } } ``` :: **Quick reference:** | MDC | Comark | | ------------------- | --------------------------------------------------- | | `result.body` | `document.nodes` | | `result.data` | `document.frontmatter` | | `result.data.title` | `document.frontmatter.title` | | `result.toc` | `document.meta.toc` (requires `toc` plugin) | | `result.excerpt` | `document.meta.summary` (requires `summary` plugin) | ### AST format The node structure is fundamentally different. MDC uses objects; Comark uses compact tuples. ::code-group ```typescript [Before] // MDC object tree { type: 'element', tag: 'p', props: { class: 'intro' }, children: [ { type: 'text', value: 'Hello world' } ] } ``` ```typescript [After] // Comark tuple array ['p', { class: 'intro' }, 'Hello world'] // tag attributes children... ``` :: **Mapping:** | MDC | Comark | | ------------------------- | -------------------------- | | `node.type === 'element'` | `Array.isArray(node)` | | `node.type === 'text'` | `typeof node === 'string'` | | `node.tag` | `node[0]` | | `node.props` | `node[1]` | | `node.children` | `node.slice(2)` | If you traverse the AST manually, update your node-walking code accordingly. ::tip{to="https://comark.dev/getting-started/document-model"} See the document model guide for the node types, parsed document structure, and traversal helpers. :: ### Plugins MDC used the `unified`/`remark`/`rehype` pipeline. Comark uses its own lighter plugin interface. #### Syntax highlighting ::code-group ```typescript [Before] import { createMarkdownParser, rehypeHighlight, createShikiHighlighter } from '@nuxtjs/mdc/runtime' const parser = await createMarkdownParser({ rehype: { plugins: { highlight: { instance: rehypeHighlight, options: { theme: 'material-theme-palenight', highlighter: createShikiHighlighter({ /* ... */ }), }, }, }, }, }) ``` ```typescript [After] import { createMarkdownParser } from 'comark' import shiki from 'comark/plugins/shiki' import githubLight from '@shikijs/themes/github-light' import githubDark from '@shikijs/themes/github-dark' const parse = createMarkdownParser({ plugins: [ shiki({ themes: { light: githubLight, dark: githubDark } }) ] }) ``` :: #### Table of contents ::code-group ```typescript [Before] import { parseMarkdown } from '@nuxtjs/mdc/runtime' const result = await parseMarkdown(md, { toc: { depth: 3 } }) const toc = result.toc ``` ```typescript [After] import { parseMarkdown } from 'comark' import toc from 'comark/plugins/toc' const document = await parseMarkdown(md, { plugins: [toc({ depth: 3 })] }) const tableOfContents = document.meta.toc ``` :: #### Excerpt / summary ::code-group ```typescript [Before] const result = await parseMarkdown(md) const excerpt = result.excerpt // MDCRoot | undefined ``` ```typescript [After] import { parseMarkdown } from 'comark' import summary from 'comark/plugins/summary' const document = await parseMarkdown(md, { plugins: [summary()] }) const summaryNodes = document.meta.summary // Node[] | undefined ``` :: #### Emoji ::code-group ```typescript [Before] // enabled by default via remark-emoji // mdc: { remarkPlugins: { 'remark-emoji': {} } } ``` ```typescript [After] import { parseMarkdown } from 'comark' import emoji from 'comark/plugins/emoji' const document = await parseMarkdown(md, { plugins: [emoji()] }) ``` :: #### Binding (`{{ }}`) MDC built-in bindings map to the opt-in [binding plugin](https://comark.dev/plugins/built-in/binding). See [Binding and data passing](#binding-and-data-passing) for the full before/after (namespaced paths, render-time `data`). ```typescript import { parseMarkdown } from 'comark' import binding from 'comark/plugins/binding' const document = await parseMarkdown(md, { plugins: [binding()] }) // Render with Binding + data — do not pass data here ``` ### Parse options ::code-group ```typescript [Before] // MDCOptions { remark: { plugins: { /* record */ } }, rehype: { options: {...}, plugins: { /* record */ } }, highlight: { theme: '...', langs: [...] } | false, toc: { depth: 3, searchDepth: 2 } | false, keepComments: false, keepPosition: false, } ``` ```typescript [After] // ParserOptions { plugins: ComarkPlugin[], // ordered array, not a record autoUnwrap: true, // removes from single-paragraph containers autoClose: true, // completes incomplete syntax (useful for streaming) // HTML, components, attributes, alerts, task-list, frontmatter are on by default } ``` :: The `mdc.config.ts` unified pipeline hooks (`pre`, `remark`, `rehype`, `post`) have no equivalent because Comark doesn't use `unified`. Use the `ComarkPlugin` interface instead. ::tip{to="https://comark.dev/plugins"} See the Plugins documentation for the full list of available plugins and how to write your own. :: ## Nuxt Module ### Configuration ::code-group ```typescript [Before] export default defineNuxtConfig({ modules: ['@nuxtjs/mdc'], mdc: { highlight: { ... }, remarkPlugins: { ... } }, }) ``` ```typescript [After] export default defineNuxtConfig({ modules: ['@comark/nuxt'], // No plugin config here, plugins are defined per-component (see below) }) ``` :: `@comark/nuxt` auto-imports `Markdown`, `MarkdownDocument`, `defineMarkdownComponent`, and `defineMarkdownDocumentComponent`. Plugin and component configuration moves out of `nuxt.config.ts` and into dedicated component definitions. ### Renderer component ::code-group ```vue [Before]
``` ```vue [After] ``` :: Props that changed: | MDC ` ` | Comark ` ` | Notes | | -------------------- | ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `body` (`MDCRoot`) | `value` (`MarkdownDocument`) | Different shape, see AST section | | `data` (frontmatter) | — | Frontmatter lives on `document.frontmatter`, not a separate prop | | — | `data` | **New meaning**: runtime values for [data binding](https://comark.dev/syntax/components#data-binding) / [`{{ }}`](https://comark.dev/plugins/built-in/binding) — not frontmatter | | `tag` | — | Wrapper is always ` ` | | `prose` | — | `Prose*` component resolution is automatic | | `unwrap` | `unwrap` prop / parse option | `` or `:options="{ unwrap: 'p' }"` — see [inline rendering](https://comark.dev/reference/parse#inline-rendering) | | `class` | — | Style the wrapper via CSS | | `components` | `components` | Same purpose, same shape | | — | `componentsManifest` | New: dynamic async component resolver | | — | `streaming` | New: streaming mode | | — | `caret` | New: animated caret for streaming | ### All-in-one component The ` ` component maps to ` `. ::code-group ```vue [Before] ``` ```vue [After] {{ markdown }} ``` :: For a pre-parsed document, use `` directly instead of passing it to the all-in-one component. ::tip{to="https://comark.dev/rendering/nuxt"} See Render Comark in Nuxt for the full component API, streaming setup, and SSR examples. :: ### `defineMarkdownComponent` In MDC, plugins were configured once in `nuxt.config.ts` and applied globally to every parse call. In Comark, you define one or more named components using `defineMarkdownComponent` and `defineMarkdownDocumentComponent`, each with its own plugins. This means different parts of your app can use different configurations without any workarounds. A typical app needs at least two configurations: a full one for articles (highlight, TOC, math…) and a lighter one for areas like comment sections that don't need all that. ```typescript [composables/markdown.ts] import { defineMarkdownComponent, defineMarkdownDocumentComponent } from '@comark/vue' import shiki from '@comark/nuxt/plugins/shiki' import toc from '@comark/nuxt/plugins/toc' import emoji from '@comark/nuxt/plugins/emoji' import githubLight from '@shikijs/themes/github-light' import githubDark from '@shikijs/themes/github-dark' import CustomAlert from '~/components/Alert.vue' import ProsePre from '~/components/ProsePre.vue' // Full-featured component for articles export const ArticleMarkdown = defineMarkdownComponent({ name: 'ArticleMarkdown', plugins: [ shiki({ themes: { light: githubLight, dark: githubDark } }), toc({ depth: 3 }), emoji(), ], components: { alert: CustomAlert, ProsePre, }, }) // Lightweight component for comments: no TOC, no highlight, no emoji export const CommentMarkdown = defineMarkdownComponent({ name: 'CommentMarkdown', }) ``` ```vue [pages/article/[slug\\].vue] {{ article.content }} {{ c.body }} ``` The same pattern applies to `defineMarkdownDocumentComponent` when you pre-parse on the server: ```typescript [composables/markdown.ts] export const ArticleMarkdownDocument = defineMarkdownDocumentComponent({ name: 'ArticleMarkdownDocument', components: { alert: CustomAlert, ProsePre, }, }) ``` ```vue [pages/article/[slug\\].vue]``` ### Slots in custom components MDC required a special ` ` component inside your custom Vue components to render children. Comark uses the native Vue ` ` element instead. #### Default slot ::code-group ```vue [Before] ``` ```vue [After] ``` :: #### Unwrapping children MDC's `` and the older ` ` attribute both map to ` `: ::code-group ```vue [Before] ``` ```vue [After] ``` :: The `unwrap` attribute strips the specified wrapper tag(s) from the rendered children. Useful when you want to remove the auto-inserted `