{"_rev":"28-35c3a0ca7b3663933fd303376aa08166","time":{"created":"2026-08-14T10:46:10.773Z","modified":"2026-08-14T10:46:11.468Z","0.0.1":"2020-09-14T09:16:30.648Z","0.0.2":"2020-09-14T09:19:08.179Z","0.0.3":"2020-09-14T09:22:32.631Z","0.0.4":"2020-09-24T09:35:50.845Z","0.0.5":"2020-09-24T09:50:44.703Z","0.0.6":"2020-09-24T09:56:25.590Z","1.0.1":"2021-01-21T18:44:32.262Z","1.0.2":"2021-01-22T11:43:46.205Z","1.0.3":"2021-02-09T09:25:36.187Z","1.0.4":"2021-04-14T08:57:14.306Z","1.0.5":"2021-06-02T07:13:26.276Z","1.0.6":"2021-07-01T11:27:31.712Z","1.0.7":"2021-08-04T11:04:08.534Z","1.0.8":"2021-08-04T11:10:36.571Z","1.0.9":"2021-08-10T06:44:50.523Z","1.0.10":"2021-09-29T10:20:16.739Z","1.0.11":"2021-11-26T07:44:56.350Z","1.0.12":"2022-01-15T16:04:44.515Z","1.0.13":"2022-01-15T16:13:07.322Z","1.0.14":"2022-02-08T15:06:16.723Z","1.0.15":"2022-04-12T12:09:11.074Z","1.0.16":"2022-06-07T11:06:23.701Z","1.0.17":"2022-06-17T10:03:52.984Z","1.1.0":"2022-06-17T10:06:58.489Z","2.0.0":"2026-08-14T10:46:11.185Z"},"_id":"@sphido/markdown","name":"@sphido/markdown","dist-tags":{"latest":"2.0.0"},"versions":{"2.0.0":{"name":"@sphido/markdown","type":"module","version":"2.0.0","author":"Roman Ožana <roman@ozana.cz> (https://ozana.cz)","homepage":"https://sphido.cz","repository":{"type":"git","url":"git+https://github.com/sphido/sphido.git","directory":"packages/sphido-markdown"},"description":"Markdown rendering extender for Sphido - A rocket 🚀 fast, lightweight, static site generator","sideEffects":false,"keywords":["sphido","markdown","satteri","gfm","renderer","html"],"engines":{"node":">=22"},"exports":{".":{"types":"./dist/markdown.d.ts","default":"./dist/markdown.js"}},"license":"MIT","publishConfig":{"access":"public"},"devDependencies":{"typescript":"^7.0.2","vitest":"^4.1.10","@sphido/frontmatter":"3.1.0"},"dependencies":{"satteri":"^0.9.5","@sphido/core":"3.1.0"},"scripts":{"test":"vitest run --passWithNoTests","build":"node -e \"fs.rmSync('dist',{recursive:true,force:true})\" && tsc"},"_nodeVersion":"26.7.0","_id":"@sphido/markdown@2.0.0","dist":{"integrity":"sha512-JG+Xkmk4OTsS5MoMem7JXJ176gyl5Mr4l1L7d7HZYBk76nfr+vYxy0Tu37rjj0WJLvPRhQicJSAcmtUpjcdvkg==","shasum":"9515dcc3a070234c4532868ffbb6d9cc3cf0ec58","tarball":"https://registry.npmjs.org/@sphido/markdown/-/markdown-2.0.0.tgz","fileCount":12,"unpackedSize":23012,"signatures":[{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEQCIGFAIWyeyoM0uvMnipa6CQG+YOqdJlcuTH0HBbEVpjp7AiBpPLsoaT/SE+J4XlC+FM53PqSt6vhoJZDk6jsKiHq51g=="}]},"_npmUser":{"name":"ozzyczech","email":"roman@ozana.cz"},"directories":{},"maintainers":[{"name":"ozzyczech","email":"roman@ozana.cz"}],"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/markdown_2.0.0_1786704371045_0.9022001222243967"},"_hasShrinkwrap":false}},"maintainers":[{"name":"ozzyczech","email":"roman@ozana.cz"}],"description":"Markdown rendering extender for Sphido - A rocket 🚀 fast, lightweight, static site generator","homepage":"https://sphido.cz","keywords":["sphido","markdown","satteri","gfm","renderer","html"],"repository":{"type":"git","url":"git+https://github.com/sphido/sphido.git","directory":"packages/sphido-markdown"},"author":"Roman Ožana <roman@ozana.cz> (https://ozana.cz)","license":"MIT","readme":"# @sphido/markdown\n\nA `page` extender that renders `page.content` from markdown to HTML, so a Sphido site no longer has to hand-roll a\nmarkdown call in its build loop. Built on [Sätteri](https://satteri.bruits.org/) — Markdown parsed and compiled in Rust,\nplugins written in JavaScript — with GFM and a safe-by-default output out of the box.\n\n## Install\n\n```bash\npnpm add @sphido/markdown\n```\n\n## Example\n\n```javascript\n#!/usr/bin/env node\n\nimport { allPages, getPages } from '@sphido/core';\nimport { frontmatter } from '@sphido/frontmatter';\nimport { hashtags } from '@sphido/hashtags';\nimport { markdown } from '@sphido/markdown';\n\nconst pages = await getPages({path: 'content'}, frontmatter, hashtags, markdown());\n\nfor (const page of allPages(pages)) {\n\tconsole.log(page.content); // HTML\n}\n```\n\n`markdown()` is a factory — it takes options and returns the extender. Put it **last**: every extender that works with\nmarkdown (front matter, hashtags, your own) has to run before the HTML exists.\n\nContent is loaded from `page.path` when it isn't in memory yet, the same way the front matter and hashtags extenders do\nit. Directories are skipped, and so is anything that isn't a markdown file — a hand-written `content/index.html` page\nreaches the output untouched.\n\n## What zero config gives you\n\n* **GFM** — tables, task lists, strikethrough, autolinks, footnotes\n* **Raw HTML escaped** — `<script>alert(1)</script>` in a markdown file renders as text, not as a script\n* **Links limited to known protocols** — `http`, `https`, `mailto`, `tel`; a `javascript:` or `data:text/html` URL loses\n  its `href`/`src` and keeps only the link text\n* **Front matter stripped** — a leading `--- ... ---` or `+++ ... +++` block never leaks into the HTML\n* **Code blocks ready for a highlighter** — `<pre><code class=\"language-javascript\">`, which is what Shiki,\n  highlight.js and Prism expect, at build time or in the browser\n\n## Options\n\n```javascript\nmarkdown({\n\tallowHtml: false,                    // keep raw HTML instead of escaping it\n\tallowedProtocols: ['http', 'https', 'mailto', 'tel'], // or false to allow every protocol\n\tfeatures: {smartPunctuation: true},  // Sätteri parser features\n\tmdastPlugins: [],                    // Sätteri markdown-AST plugins\n\thastPlugins: [],                     // Sätteri HTML-AST plugins\n\tsanitize: (html, page) => html,      // post-process the rendered HTML\n\trender: (markdown, page) => html,    // replace the engine altogether\n\tinclude: (dirent) => dirent.name.endsWith('.md'), // which files to render\n})\n```\n\nBoth safe defaults are ordinary hast plugins (`escapeRawHtml` and `safeUrls()`, both exported) placed **before** your\nown, so a plugin of yours can still emit raw HTML — a syntax highlighter, for instance.\n\n### Syntax highlighting at build time\n\n```javascript\nimport { markdown } from '@sphido/markdown';\nimport { createHighlighter } from 'shiki';\n\nconst highlighter = await createHighlighter({themes: ['github-light'], langs: ['javascript']});\n\nconst shiki = {\n\tname: 'shiki',\n\telement: {\n\t\tfilter: ['pre'],\n\t\tvisit(node, ctx) {\n\t\t\tconst code = node.children?.[0];\n\t\t\tif (code?.type !== 'element' || code.tagName !== 'code') return;\n\t\t\tconst lang = (code.properties?.className ?? []).map(String)\n\t\t\t\t.find((name) => name.startsWith('language-'))?.slice(9);\n\t\t\tctx.replaceNode(node, {\n\t\t\t\ttype: 'raw',\n\t\t\t\tvalue: highlighter.codeToHtml(ctx.textContent(node), {\n\t\t\t\t\tlang: highlighter.getLoadedLanguages().includes(lang) ? lang : 'text',\n\t\t\t\t\ttheme: 'github-light',\n\t\t\t\t}),\n\t\t\t});\n\t\t},\n\t},\n};\n\nconst pages = await getPages({path: 'content'}, markdown({hastPlugins: [shiki]}));\n```\n\n### Heading anchors\n\n```javascript\nimport slugify from '@sindresorhus/slugify';\n\nconst anchors = {\n\tname: 'heading-anchors',\n\telement: {\n\t\tfilter: ['h2', 'h3'],\n\t\tvisit(node, ctx) {\n\t\t\tconst id = slugify(ctx.textContent(node));\n\t\t\tctx.setProperty(node, 'id', id);\n\t\t\tctx.prependChild(node, {\n\t\t\t\ttype: 'element',\n\t\t\t\ttagName: 'a',\n\t\t\t\tproperties: {className: ['anchor'], href: `#${id}`},\n\t\t\t\tchildren: [{type: 'text', value: '#'}],\n\t\t\t});\n\t\t},\n\t},\n};\n```\n\nA plugin object is reused for every page. When yours needs per-page state — a slug counter that has to reset, say — pass\na factory instead and Sätteri calls it once per document: `hastPlugins: [() => anchors()]`.\n\n### Raw HTML and a real sanitizer\n\nEscaping is the whole of the default protection, so it is on until you turn it off. If your content needs raw HTML,\nallow it and hand the output to a sanitizer:\n\n```javascript\nimport DOMPurify from 'isomorphic-dompurify';\n\nmarkdown({\n\tallowHtml: true,\n\tsanitize: (html) => DOMPurify.sanitize(html),\n})\n```\n\n`sanitize` runs on whatever came out of the renderer, including a custom `render`.\n\n### Another engine\n\n`render` replaces Sätteri entirely — the rest of the extender (file loading, directory and non-markdown skipping,\n`sanitize`) keeps working:\n\n```javascript\nimport { marked } from 'marked';\n\nmarkdown({render: (md) => marked.parse(md)})\n```\n\n```javascript\nimport { unified } from 'unified';\nimport remarkParse from 'remark-parse';\nimport remarkGfm from 'remark-gfm';\nimport remarkRehype from 'remark-rehype';\nimport rehypeSanitize from 'rehype-sanitize';\nimport rehypeStringify from 'rehype-stringify';\n\nconst processor = unified().use(remarkParse).use(remarkGfm).use(remarkRehype)\n\t.use(rehypeSanitize).use(rehypeStringify);\n\nmarkdown({render: async (md) => String(await processor.process(md))})\n```\n\n## Why Sätteri\n\nMeasured on this repository before the package was written — macOS on Apple silicon, Node.js 26, 1 000 blog-shaped\nmarkdown pages (3.9 MB corpus: headings, paragraphs, lists, a GFM table, a fenced code block, a quote), median of three\nruns, each engine installed into its own empty project:\n\n| GFM → HTML | installed size | packages | 1 000 pages | whole process |\n|---|---|---|---|---|\n| marked 18.0.9 | 480 kB | 1 | 45 ms | 0.10 s |\n| remark/rehype (unified 11) | 6.5 MB | 84 | 566 ms | 0.65 s |\n| **satteri 0.9.5** | 3.0 MB | 7 + native binary | **13 ms** | **0.06 s** |\n| **`markdown()` as shipped** | | | **21 ms** | **0.08 s** |\n\nThe last row is the preset in this package: Sätteri plus the two guard plugins, which walk the HTML AST from JavaScript\nand cost roughly 8 ms per 1 000 pages. Even with them it stays twice as fast as bare marked and 27× faster than\nremark/rehype.\n\nThe feature spike (GFM + heading anchors + Shiki + sanitization) landed all three engines at 1.1–1.4 s per 1 000 pages —\nhighlighting and sanitizing dominate, the parser does not. What separated them was everything else:\n\n* **marked** is one 480 kB dependency, but its extensibility ceiling is real: anchors and Shiki both meant replacing\n  whole renderer methods, and it passes raw HTML and `javascript:` URLs straight through.\n* **remark/rehype** has the ecosystem (`rehype-slug`, `rehype-sanitize` at 296 kB, `@shikijs/rehype`) and drops raw HTML\n  by default, but 84 packages and 566 ms for plain GFM is a lot to ask of every Sphido site.\n* **Sätteri** parses in Rust and exposes the same mdast/hast shapes the unified ecosystem uses, so a plugin is a plain\n  object with visitors — the two guards in this package are 20 lines together. The trade-offs are its age (0.9.x, MIT,\n  first released March 2026, actively developed) and prebuilt native binaries: `x64`/`arm64` for Linux (gnu and musl),\n  macOS and Windows, plus a `wasm32-wasi` fallback.\n\nSanitization is the one place the engines differ by an order of magnitude in weight: `rehype-sanitize` is 296 kB, while\na DOM-based `DOMPurify` pass drags in jsdom at 28 MB. That is why the default here is escaping rather than a bundled\nsanitizer — no dependency, and the escape hatch is one option away.\n\nIf the trade-off doesn't suit your project, `render` makes the engine a one-line decision.\n\n## TypeScript\n\nThe extender adds no new fields to a page — it rewrites `page.content` — so there is no `With*` type to compose. The\noption types are exported:\n\n```typescript\nimport { getPages, type Page } from '@sphido/core';\nimport { markdown, type MarkdownOptions, type MarkdownRender } from '@sphido/markdown';\n\nconst render: MarkdownRender = (md, page) => `<article data-name=\"${page.name}\">${md}</article>`;\nconst options: MarkdownOptions = {render};\n\nconst pages = await getPages<Page>({path: 'content'}, markdown(options));\n```\n\nAlso exported for reuse: `isMarkdown` (the default file filter), `isSafeUrl`, `defaultProtocols`, `escapeRawHtml`,\n`safeUrls()`, `compileOptions()` and `createRenderer()`.\n\n## Source code\n\n[@sphido/markdown](https://github.com/sphido/sphido/tree/main/packages/sphido-markdown)\n","readmeFilename":""}