inkstream
Markdown engine for Mintlify-style JSX tags, Zenn directives, and GFM, built as remark/rehype plugins over the mdast tree rather than a line-based string preprocessor. Supersedes an earlier draft (internally called "v1" below) that converted the same JSX tags to colon-fence directives with a ~1,850-line line-based preprocessor; that draft is frozen and unpublished.
Pipeline
normalizeMintlifyBlocks(markdown)— line-based pre-pass that surrounds standalone tag lines with blank lines (skipping code fences) so remark parses each tag as its ownhtmlflow node, strips the authoring indentation inside open tags (up to the open tag's indent + 4, mirroring v1) so indented tag bodies don't become indented code blocks, and flattens JSX array attributes (tags={["A", "B"]}→tags="A,B"), which would otherwise make the tag invalid HTML for remark.remark-parse— standard markdown parsing.remarkMintlifyTags— pairs open/closehtmlnodes with a stack (once for each parent's flow children, once for each paragraph's phrasing children) and lifts the nodes between a pair into amintlifyContainernode carryingname,attributes, anddata.hName/hPropertiesfor remark-rehype. Block tag names (Note,Card,Steps, ...) pair at flow level or as a whole single-line paragraph; inline tag names (Badge,Tooltip) pair mid-paragraph without disturbing surrounding text.remarkCodeFenceComponents— converts```tree,```quiz, and```chart:bar/```chart:radarfenced code blocks intomintlifyContainernodes carrying the parsed structure as one JSON-string property (tree,quiz,chart) for a renderer component to read. Malformed fences emit a vfile warning and are left as plain code blocks.normalizeZennDirectiveShorthand(markdown)(run before step 1, alongsidenormalizeMintlifyBlocks) +remark-directive+remarkZennDirective— support for the native:::message/:::detailsauthoring syntax an author can write directly (as opposed to the Mintlify JSX tags, which v2 never routes through colon-fences at all). The shorthand normalizer rewrites the friendly:::message alert/:::details タイトルforms into the{.class}/[label]syntaxremark-directiverequires;remarkZennDirectivethen reads the resultingcontainerDirectivenodes remark-directive produces. This is the one piece of the pipeline that still needs a third-party directive parser, since colon-fence syntax itself isn't something a tag-pairing plugin overhtmlnodes can parse. Like v1, the normalizer protects code fences and inline code spans, so literal`:::message alert`examples in prose survive; it also reduces Zenn's@[card](url)/@[github](url)embeds to bare URL lines for a linkify-style renderer to pick up.remarkGithubAlerts— normalizes GitHub blockquote alerts (> [!NOTE]etc.) onto the sameaside.msgcontract as the Mintlify callouts and:::message.remarkTreeTags— converts a paired JSX<Tree><Tree.Folder>…</Tree>block (captured byremarkMintlifyTags) into the same JSON-carryingtreenode the```treefence produces.normalizeZennImages(markdown)+parseImageMetadata(url)— Zenn's image sizing/caption syntax (, a*caption*line under the image). The sizing suffix lives inside the markdown image destination, where remark's parser refuses spaces, so this stays a line-based step that encodes the metadata into query parameters; an image renderer reads them back withparseImageMetadata.
Using the library: two layers
inkstream is consumed at two distinct levels, and every feature belongs
to exactly one of them. The boundary is the .ink-markdown element:
inside it is the library's job; outside it — and everything that needs
app-specific knowledge — is the consumer's job.
Layer 1 — Drop-in (zero config)
The /react subpath renders inkstream markdown in one line, and the core
package stays React-free:
import { InkstreamMarkdown } from '@catatsumuri/inkstream/react';
<InkstreamMarkdown>{markdownSource}</InkstreamMarkdown>;
Everything below works with no props and no app-side code:
- Headings with slug ids and copy-link anchors (h1–h4)
- Callouts in all three syntaxes (Mintlify
<Note>tags,:::message, GitHub> [!NOTE]alerts) normalized onto oneaside.msgcontract - Every Mintlify component: Card/CardGroup, Steps, Tabs, Accordion, Badge, Tooltip, Update, ResponseField/ParamField, CodeGroup, Tree
- Code blocks: Shiki highlighting, copy button, wrap toggle, filename
headers (
```php:index.php), diff mode (```diff js:app.js) ```mermaiddiagrams (lazy-loaded chunk, optional peer dependency)```tree,```quiz,```chart:bar/```chart:radarfences- Zenn image sizing/captions (
,*caption*line) - YouTube embeds from standalone URLs
- GitHub file embeds from standalone blob URLs
(
raw.githubusercontent.comallows CORS, so the browser fetches directly — no server help needed) - A default look via an optional stylesheet:
import '@catatsumuri/inkstream/styles.css';. Every rule targets a stableink-*class name inside@layer inkstream, so any unlayered rule a consumer writes overrides it without a specificity fight, and eight semantic colors (border/foreground/muted/primary/accent/card) read from--ink-*custom properties with neutral fallbacks — see the file's header comment for the full list and a theme-bridge example.
The renderers themselves stay style-agnostic (only class names, no
inline styles) — styles.css is what turns Layer 1's correct structure
into something that looks right without any app code. Skip the import
entirely for unstyled markup, or import it and override just the
--ink-* bridge (Layer 2) to match an existing design system.
Layer 2 — App integration surface
These features are split "parsing in the library, knowledge in the app": the library ships the syntax support and an injection point, and degrades gracefully when the app doesn't provide one. kb_practice is the reference implementation for each.
| Integration point | What the app supplies | Without it | kb_practice reference |
|---|---|---|---|
| Theme bridge | --ink-* custom properties pointing at the app's own design tokens |
The package's neutral (zinc-style) default colors | resources/css/inkstream.css maps --ink-border → --border, etc. |
resolveWikilink prop |
[[path]] → URL (routing/DB lookup) |
[[...]] stays literal text |
title→id map; unresolved links route to the create form |
ogpEndpoint prop |
Server-side OGP proxy (CORS blocks direct fetch) | URL-only link cards | OgpController (validation + 24h cache) |
extractMarkdownHeadings + headingIdPrefix |
Table-of-contents UI outside .ink-markdown |
No TOC (anchors still work) | DocumentTableOfContents scrollspy |
components prop |
Per-tag renderer overrides | Default ink-* renderers |
— |
| Dark mode | dark class on the document root + CSS for the --shiki-* variables |
Light theme only | Tailwind .dark convention |
| Page behaviors | Anything tied to navigation lifecycle (e.g. hash-anchor scroll restore in an SPA, where content mounts after the browser's native jump) | Browser defaults | hash restore on Inertia navigation |
Core-only consumers (no React) can use normalizeInkstreamMarkdown +
inkstreamRemarkPlugins from the root entry point in any unified
pipeline — the golden corpus renderer does exactly this. react and
react-markdown are optional peer dependencies, so this path pulls in
no React at all.
What the AST approach fixes structurally
- No nesting limit — v1 encoded depth in colon-fence length (7 levels
max,
10 − depthcolons); v2 nesting is just tree structure. - Single-line tags —
<Note>text</Note>in one paragraph works. - Self-closing tags —
<Card title="..." />. - Inline tags —
<Badge>/<Tooltip>pair mid-sentence. - Error tolerance with diagnostics — unmatched close tags stay literal, unclosed tags auto-close at end of parent, and both emit vfile warnings instead of failing silently. Malformed tree/quiz/chart fences fall back to a plain code block with a warning instead of silently dropping data.
- No redundant raw-JSON dump — v1's tree/quiz/chart directives leave
the source fence's
codechild in the tree alongside the JSON attribute, so the raw JSON also renders as a visible<pre><code>block. v2'smintlifyContainerhas no children for these, so only the intended component renders.
Not yet implemented (planned)
- Per-component attribute schemas (currently reuses v1's global allowlist
for
hProperties; the full parsed attribute map is kept on the node). - Tags inside blockquotes/lists currently require blank lines around them
(the normalizer only handles top-level tag lines); fixing this means
splitting multi-line
htmlnodes or normalizing per container. - Multi-line JSX open tags (an open tag with attributes spread across
several lines; v1 joins them in
joinMultilineJsxTags). - Attribute naming convention: v1 prefixes hProperties with
data-<component>-<attr>(e.g.data-card-href), presumably because its output was raw custom HTML elements read viadataset. v2 intentionally uses raw names (href) since containers render through React components that read props directly — this is a deliberate v2 API change, not a gap to close. - Registry publish. The build (
npm run build→dist/with.d.ts, exports pointing at it,styles.csscopied alongside), theinkstreamCLI (see below), and CI (typecheck/test/build/golden, plus a check that committeddist/matches a fresh build) are already in place; this package isn't published to the npm registry yet ("private": true) — consumed via a GitHub commit pin instead, which is whydist/is committed rather than gitignored: npm'spreparescript (which would otherwise build it automatically on install) only runs when the installing environment allows lifecycle scripts, and plenty of reasonable npm configs setignore-scripts=true.preparestill runsnpm run buildfor anyone who does allow scripts (and will matter again once this is a real registry publish, where it runs on the publisher's machine regardless of the installer's setting).
CLI
npx --package=github:catatsumuri/inkstream inkstream <command> [file]
or, once installed as a project dependency, npx inkstream ... /
directly via the inkstream bin.
| Command | Output | Use case |
|---|---|---|
inkstream render <file|-> |
HTML | RSS feeds, OGP static pages, email bodies. Same pipeline as the golden corpus renderer, so no heading ids (those come from the React heading renderer, not this pipeline) |
inkstream text <file|-> |
Plain, human-readable text | Full-text search indexing, excerpts, OGP description — the raw markdown source is unusable for these as-is (<Card title=...>, ```quiz syntax noise) |
inkstream headings <file|-> [--json] [--prefix=<p>] |
Indented outline, or a JSON array with --json |
Server-side table-of-contents precomputation; same shape as extractMarkdownHeadings |
Every command reads markdown from the given file path, or from stdin
when the path is - or omitted.
text resolves the same syntax render does (Mintlify tags, Zenn
directives, wikilinks) down to prose: a quiz fence contributes its
question/options/explanation, a chart fence its title and labels (not
the numeric values), a tree fence its file and folder names, and a
wikilink its label (or the path's last segment) — none of these have a
sensible plain-text form otherwise, so they're handled explicitly rather
than silently dropped or dumping raw JSON. Wikilinks resolve without
needing a resolveWikilink callback, the same way heading slugs do.
Known limitations (not planned)
Edge cases identified during review, deliberately left unhandled: no occurrence in this project's real documents justified the added parsing surface, and each is easy to work around in authoring.
- Setext headings (
Titleunderlined by===/---instead of a leading#) aren't picked up byextractMarkdownHeadings— a table of contents silently omits them, even though they render fine as<h1>/<h2>. Write headings with#/##instead. >inside a tag attribute value (<Card title="a > b">) breaksmatch-tags.ts's line-regex tag matcher, since it isn't a real HTML parser. Avoid a literal>in attribute values (use>or rephrase).- Single-quoted attribute values (
<Card title='x'>) aren't parsed byparseJsxAttributes' bare/quoted branches (only"x"and brace-wrapped{'x'}are). Use double quotes.
Golden corpus (golden/)
Snapshot regression suite: renders every golden/corpus/*.md fixture
through the full pipeline and compares the prettified HTML against the
committed baseline in golden/baseline/*.html.
npm run golden # check; exits non-zero on any diff
npm run golden -- --update # re-render and accept the current output
golden/render-v2.ts— the pipeline under test (normalizeInkstreamMarkdown→inkstreamRemarkPlugins→remark-rehype→hast-util-to-html), the same exports consumers use.golden/diff.ts— prettifies output to one tag per line, line-diffs it against the baseline, and writes the current output togolden/output/*.html(gitignored) for inspection.
Fixtures 01–09 are synthetic; 10–14 are the five real syntax-guide
documents from thinkstream's SyntaxSeeder (basic markdown, extended
markdown/GFM, Zenn syntax, Mintlify syntax, thinkstream syntax) — a far
truer signal, and the corpus that surfaced the fence-state, inline-code,
indentation, and array-attribute bugs the current pipeline fixes.
The corpus originally drove a v1-vs-v2 comparison (rendering the same
fixtures through inkstream v1's directive pipeline); once every difference
was either fixed or decided as an intentional v2 change, the v1 renderer
and its file: dependency were dropped and the v2 output became the
baseline.