Compare commits

2 Commits

Author SHA1 Message Date
22befaaa71 feat: render richer blocks and inline nodes (task 0004)
Extend the core parser and the CLI renderer to cover the everyday Markdown
body beyond headings and paragraphs: lists, blockquotes, fenced code,
thematic breaks, and inline emphasis, strong, code spans, and links.

Add remark-gfm to the pipeline so tables and strikethrough parse as their
own nodes. Any block or inline node core does not model falls through to
RawBlock / RawInline, whose verbatim value is position-sliced from the
original input so unmodelled constructs round-trip byte-for-byte.
2026-07-29 07:34:56 -04:00
ab309d3226 feat: add kitchen view command and parser skeleton (task 0003)
Cut the first vertical slice through both layers: a runnable
`kitchen view <file>` that reads a Recipe File, parses it, and renders
it styled to the terminal.

@kitchen-md/core exposes a pure, total `parse(input): DocumentAST` built
on a minimal remark pipeline (parse + frontmatter) with a translation
layer to core's own AST types — frontmatter passthrough, HeadingBlock,
ParagraphBlock, and TextNode. remark types never leak into the public
API.

@kitchen-md/bin's `view` subcommand (commander) reads the file and hands
the DocumentAST to a pure `render(ast): string` (chalk, ANSI
auto-suppressed off a TTY). Fallible file I/O is modelled as a neverthrow
Result over a tagged-union CliError, matched at the boundary: stdout on
success, stderr and exit 1 on failure. The command functions sit beside
the import.meta.main-guarded CLI entry so they are testable in-process.

Tests follow ADR 0009's tiers — unit (parse, render), integration (the
view command's Result), and e2e (the binary as a subprocess) — with
coverage, a path-scoped test-report generator, and a prose fixture
rounding out the tooling. ADR 0008 records errors-as-values at the CLI
boundary; ADR 0009 records the testing tiers.
2026-07-28 23:31:03 -04:00
27 changed files with 2086 additions and 118 deletions

View File

@@ -0,0 +1,31 @@
# ADR 0008 — Errors as Values at the CLI Boundary
**Status**: Accepted
## Context
`@kitchen-md/bin`'s `view` command performs a fallible operation: reading a file from disk.
Node's `readFileSync` signals failure by throwing.
Two idioms are available: let exceptions propagate and catch them at the entry point, or represent failure as a value the type system tracks.
`@kitchen-md/core`'s parser already takes the second path — it is a total function that never throws and reports problems through the Document AST's diagnostics (see ADR 0004).
## Decision
Model fallible operations in `@kitchen-md/bin` as a neverthrow `Result<T, E>`, with error types expressed as plain-data tagged unions (e.g. `ViewError = { tag: "read-failed"; … }`).
A throwing API is wrapped in a small adapter that converts the exception into an `err`, so nothing above the adapter leaks exceptions.
The CLI entry point matches the `Result` at the boundary: stdout on success, stderr plus a non-zero exit on failure.
## Rationale
It keeps the bin layer consistent with core's total-function stance, so the whole codebase treats recoverable failure as data rather than control flow.
The possibility of failure becomes visible in a function's signature instead of hidden behind a `throw`.
The success value cannot be read without first handling the error case, which removes a class of mistakes at compile time.
A tagged-union error type gives exhaustive handling: a new failure mode is a new tag that every match must account for.
Because failure is a returned value rather than a side effect, error propagation can be asserted in-process by the integration tier, without spawning the binary (see ADR 0009).
## Consequences
`@kitchen-md/bin` takes a dependency on neverthrow.
`try`/`catch` is confined to the thin adapters that wrap throwing APIs, and the rest of the layer is exception-free.
Must-use enforcement — that a `Result` is never silently dropped — is a convention (always terminate a `Result` at a `.match` at the boundary), not a lint rule, because the project uses Biome alone and does not add ESLint's `eslint-plugin-neverthrow` for now.
New failure modes stay additive: a new tag on the error union, handled at the boundary.

View File

@@ -0,0 +1,44 @@
# ADR 0009 — Testing Tiers and Boundaries
**Status**: Accepted
## Context
The test suite accumulated overlapping files — unit, integration, smoke, and subprocess — with no crisp definition of what each was responsible for.
The result was duplication and confusion: the same behaviour asserted in more than one tier, and no rule for where a given test belonged.
Both specs already referred to unit, integration, and smoke tests, but none of them pinned the boundaries.
## Decision
Recognise three test tiers, each defined by the seam it exercises.
**Unit** — one component in isolation, a pure function, asserted by its return value.
`parse` in core and `render` in bin are the unit seams.
**Integration** — several components composed in-process, asserted by the value the composed function returns.
The `view` command function — file read, then `parse`, then `render`, returning a `Result` — is the integration seam.
**E2E** — the binary as a black box.
It is spawned as a subprocess and asserted on its exit code and its stdout and stderr, with no knowledge of the internal structure.
The line between what is unit- or integration-testable and what is e2e-only is whether a function returns a value or performs a process-level side effect.
A function that returns a value can be asserted in-process.
A function that calls `process.exit` or `process.stdout.write` can only be observed by spawning the binary.
So all logic is pushed into value-returning functions, and the entry shell is kept as thin as possible, because it is the one part reachable only through a subprocess.
Core exposes a single public seam, `parse`, so it has unit tests only.
A whole-fixture parse test is still a unit test on that same seam with a broad input — a corpus test — not a separate tier.
## Rationale
Precise, non-overlapping definitions prevent the duplication that a vague unit-integration-smoke split produced.
Classifying by seam matches what is actually cheap or expensive to test.
Value-returning code runs fast and is visible to coverage in-process, while process-side-effecting code needs a subprocess and is invisible to coverage.
Concentrating the process-boundary surface in one thin shell keeps the amount of e2e-only code to a minimum.
## Consequences
Each behaviour is tested at exactly one tier: logic at unit or integration in-process, the process boundary at e2e.
The e2e tier is deliberately minimal — it verifies wiring such as exit codes and stream routing, not content already proven in-process.
Tests are co-located as `{module}_test.ts`, so the command-function tests live beside the entry module and are integration tests despite the file name.
When a command function shares a file with the top-level `program.parse()`, that call is guarded with `import.meta.main`, so importing the module for a test does not run the CLI.

View File

@@ -0,0 +1,77 @@
---
spec: cli-view
---
## What to build
The walking skeleton: a runnable `kitchen view <file>` that reads a Recipe File, parses it, and prints it styled in the terminal.
This slice cuts the first complete thread through both layers with the smallest set of node types.
In `@kitchen-md/core`, stand up the `parse` function and the types module, modelling only what this slice renders: frontmatter passthrough, `HeadingBlock`, `ParagraphBlock`, and `TextNode`.
Set up the minimal internal remark pipeline needed for these (parse + frontmatter), with the translation layer from remark's output to core's own types.
`parse` is a pure, total function returning a `DocumentAST` of shape `{ frontmatter, blocks, diagnostics }`.
In `@kitchen-md/bin`, implement the `view` subcommand with commander taking one required file-path argument.
The entry point owns file I/O: it reads the file, calls `parse`, and passes the `DocumentAST` to a pure `render(ast)` function.
`render` walks the AST and returns an ANSI-styled string using chalk, which auto-suppresses colour when stdout is not a TTY.
Frontmatter prints as raw YAML followed by a visual separator, then the body: headings styled by level, paragraphs as prose with blank-line spacing.
The demoable outcome: `kitchen view <recipe.md>` shows metadata, headings, and prose; a missing file prints a human-readable error to stderr and exits non-zero.
## Acceptance criteria
- [x] `parse` returns a `DocumentAST` `{ frontmatter, blocks, diagnostics }`; frontmatter is a plain object (empty when absent), diagnostics empty in the normal case
- [x] Core types live in a dedicated types module and are re-exported from the package entry point alongside `parse`
- [x] Headings (levels 16) and paragraphs are modelled as `HeadingBlock` and `ParagraphBlock`, with paragraph content as an inline array of `TextNode`
- [x] Blocks are flat and in document order (a heading is a sibling of the following paragraph, not its parent)
- [x] remark types do not appear in core's public API
- [x] `kitchen view <file>` reads the file, calls `parse`, and prints the rendered output
- [x] `render(ast)` is pure (no I/O, no side effects) and returns an ANSI-styled string
- [x] Frontmatter renders as raw YAML before the body, followed by a visual separator
- [x] Headings render bold and distinct by level; paragraphs render prose followed by a blank line
- [x] chalk styling is suppressed automatically when stdout is not a TTY
- [x] A missing file path prints commander usage to stderr and exits 1; an unreadable/nonexistent file prints a human-readable error to stderr and exits 1
- [x] Renderer unit tests (ANSI stripped) cover frontmatter, the separator, headings, and paragraphs
- [x] Core unit tests cover frontmatter passthrough (arbitrary fields, empty, absent), headings at every level, and paragraphs with `TextNode` content
## Implementation Notes
All acceptance criteria are met. The following decisions and scope boundaries are worth recording.
### Scope boundaries carried by the slice
Only `HeadingBlock`, `ParagraphBlock`, and `TextNode` are modelled, as the slice specifies.
The remark→core translation therefore skips any block that is not a heading or paragraph (lists, blockquotes, code, thematic breaks), and `translateInline` keeps only text nodes, dropping every other inline node type.
The drop is by whole node, so emphasised or linked text is currently lost, not merely unstyled.
This is within task 0003's stated scope; the lossless `RawInline`/`RawBlock` fallback and the typed `EmphasisNode`/`StrongNode`/`LinkNode` land in task 0004, which also makes the translation recurse into container children.
Malformed-frontmatter handling is out of scope here and owned by task 0007.
This slice parses well-formed frontmatter and returns `{}` for the empty and absent cases; a genuinely malformed YAML block would currently throw from the YAML parser.
The total-function guarantee for that case (returning `{}` plus an `invalid-frontmatter` diagnostic) arrives with 0007.
### Types defined ahead of full use
`Diagnostic`, `Point`, and `Position` are defined in the types module because `DocumentAST.diagnostics` is typed `Diagnostic[]`, even though only the empty case (`diagnostics: []`) is produced in this slice.
This keeps the public shape stable; 0007 populates the channel.
### Rendering decisions
Headings render distinct-by-level via chalk, tapering from bold at level 1 toward dim at level 6; a heading is followed by a single newline and a paragraph by a blank line, which is what visually separates them once ANSI is stripped.
Per the cli-view spec, the specific colour and weight choices are visual decisions verified by inspection, not asserted in tests — the renderer tests assert ANSI-stripped text, spacing, and the presence/absence of styling, not particular colours.
The frontmatter separator is a dimmed 40-character box-drawing rule.
### Test suite structured on ADR 0009's tiers
The suite is organised by the three tiers ADR 0009 defines, each seam tested at exactly one tier.
Because task 0003's code was already built, these are characterization tests — green on arrival — asserting observable behaviour against independent literals rather than restating the implementation.
Unit tests cover the two pure seams.
`packages/core/src/parse_test.ts` asserts `parse` through the package barrel: frontmatter passthrough for arbitrary, empty, and absent blocks, headings at every level 16, paragraphs with `TextNode` content, flat document order, and empty diagnostics.
`packages/bin/src/render_test.ts` asserts `render` on ANSI-stripped output: headings at every level, paragraph blank-line spacing, the frontmatter YAML with its separator, and their absence when frontmatter is empty.
The `view` command function and the CLI share `packages/bin/src/index.ts`: `viewFile`, `formatError`, and the `CliError` union are value-returning and tested in-process, while the thin `program.parse()` dispatch is guarded behind `import.meta.main` so importing the module for a test never runs the CLI.
Integration tests (`packages/bin/src/index_test.ts`) assert that command function's returned `Result` in-process: `ok` with rendered output for a readable file, frontmatter passthrough, a `read-failed` error for a missing path, and `formatError`'s message.
End-to-end tests (`packages/bin/src/end_to_end_test.ts`) drive the `kitchen` binary as a subprocess, asserting exit codes, stream routing, document order, ANSI suppression on a pipe, the missing-file and missing-argument errors, and `--help`.
`fixtures/prose.md` is a committed recipe using only the constructs this slice models — frontmatter, headings at levels 13, and plain-text paragraphs — so it renders losslessly today.
The full `fixtures/basic.md` end-to-end remains task 0007's, once 0004's richer nodes make that fixture render losslessly.

View File

@@ -0,0 +1,69 @@
---
spec: core-parser
blocked-by: 0003-view-skeleton
---
## What to build
Extend the parser and renderer to cover the rest of the everyday Markdown body, so `kitchen view` renders lists, blockquotes, code, rules, and inline emphasis faithfully.
In `@kitchen-md/core`, model the remaining block types: `ListBlock` (ordered flag + items) whose items are `ListItemBlock` containers holding `Block[]`, `BlockquoteBlock` as a container holding `Block[]` (OFM callouts like `> [!note]` parse as ordinary blockquotes), `CodeBlock` (optional language + literal text, no annotation parsing), and `ThematicBreakBlock`.
Add the inline nodes: `EmphasisNode`, `StrongNode`, `CodeSpanNode`, and `LinkNode` (href + inline content).
Add the raw fallbacks: any remark block or inline node core does not model falls through to `RawBlock` / `RawInline`, whose verbatim `value` is captured by position-slicing the original input (never re-stringified), so unmodelled constructs round-trip byte-for-byte.
Add remark-gfm to the pipeline; the translation layer recurses into container children rather than collapsing them.
In `@kitchen-md/bin`, extend `render` for the new nodes: lists (bullet for unordered, sequential number for ordered, one item per line), code blocks (literal, no highlighting), thematic breaks (a horizontal rule string), emphasis (italic), strong (bold), code spans (a distinct dim/inverse style), and links (inline content only, href not shown).
## Acceptance criteria
- [x] `ListBlock` carries an ordered flag and an array of `ListItemBlock`; each `ListItemBlock` is a container wrapping child blocks (e.g. a paragraph), not a bare inline array
- [x] `BlockquoteBlock` is a container holding `Block[]`; a callout (`> [!note]`) parses as an ordinary blockquote with its text preserved
- [x] `CodeBlock` carries an optional language and literal text, and its content is not annotation-parsed
- [x] `ThematicBreakBlock` is modelled
- [x] Inline `EmphasisNode`, `StrongNode`, `CodeSpanNode`, and `LinkNode` are modelled; code-span content is not annotation-parsed
- [x] An unmodelled block (e.g. a GFM table) falls through to `RawBlock` and an unmodelled inline (e.g. strikethrough) to `RawInline`, each preserving byte-for-byte verbatim source via position-slicing
- [x] `render` handles ordered and unordered lists, code blocks, thematic breaks, and blockquotes
- [x] `render` handles emphasis (italic), strong (bold), code span (distinct style), and link (inline content only)
- [x] Core unit tests cover each new block type, the list-item-wraps-a-paragraph shape, each new inline node, and the raw block/inline fallbacks with verbatim source
- [x] Renderer unit tests (ANSI stripped) cover each new block and inline node
## Implementation Notes
All acceptance criteria are met, with no dropped or deferred criteria.
The following decisions are worth recording.
### Node discriminants mirror mdast
The new nodes reuse mdast's own type names as their discriminants — `list`, `listItem`, `blockquote`, `code`, `thematicBreak`, `emphasis`, `strong`, `link` — matching the precedent set by task 0003 (`text`, `heading`, `paragraph`).
The two exceptions are the code span (`codeSpan`, since the interface is `CodeSpanNode` and mdast's `inlineCode` reads oddly in this AST) and the raw fallbacks (`raw` for blocks, `rawInline` for inlines), which have no mdast counterpart.
### `ListItemBlock` is scoped to `ListBlock.items`, not the `Block` union
A list item exists only as a member of a list, so `ListItemBlock` is deliberately kept out of the top-level `Block` union and the `renderBlock` switch.
List items are dispatched through `renderItemContent`, reached only from `renderList`.
This keeps the type as tight as the spec's "an array of `ListItemBlock`" shape rather than letting a list item appear anywhere a block is valid.
### `CodeBlock.lang` is omitted, not `undefined`, when absent
`lang` is an optional property that is left off entirely for an unlabelled fence rather than set to `undefined`, so `{ type: "code", value: "…" }` deep-equals the parser output without a stray `lang: undefined` key.
### Frontmatter must be excluded from the raw fallback
With the raw fallback now catching every unmodelled block, the `yaml` node — captured separately as frontmatter — is explicitly dropped in `translateBlock` so it does not also surface as a `RawBlock` and double-render.
### Content-not-annotation-parsed is satisfied structurally
Code blocks and code spans store their literal `value` verbatim and are never fed to `translateInline`.
Annotation parsing itself lands in task 0006, so "not annotation-parsed" holds here by construction: there is no annotation pass for that content to escape.
### Rendering decisions (visual, not asserted by colour)
Following task 0003, the renderer tests assert ANSI-stripped text, spacing, and the presence/absence of styling — never particular colours.
Unordered items use a `•` bullet, ordered items a sequential `1.`-from-one number (the model carries only the `ordered` flag, not a start offset), one item per line.
Blockquote lines are prefixed with a dim `│ `, code blocks and the thematic-break rule render dim, code spans use `inverse` to stay distinct from the dim used elsewhere, emphasis is italic, strong is bold, and links render their inline content underlined with the href hidden.
The list-item and blockquote renderers collapse child-block trailing spacing (`trimEnd`) so nested paragraphs sit on the marker/quote line rather than emitting their own blank line.
### remark-gfm
`remark-gfm` is added to the core pipeline so tables and strikethrough parse as their own nodes (`table`, `delete`) and therefore reach the raw fallback, where position-slicing captures them byte-for-byte.
Without gfm they would parse as ordinary paragraph text and never exercise the fallback.

6
.gitignore vendored
View File

@@ -7,6 +7,12 @@ packages/bin/kitchen
result
result-*
# test coverage output
coverage/
# generated test report and its intermediate artifacts
reports/
# direnv / nix-direnv cache
.direnv/

184
bun.lock
View File

@@ -8,6 +8,7 @@
"@biomejs/biome": "^2.5.3",
"bun-types": "latest",
"bun2nix": "^2.1.2",
"fast-xml-parser": "^5.10.1",
},
},
"packages/bin": {
@@ -18,11 +19,22 @@
},
"dependencies": {
"@kitchen-md/core": "workspace:*",
"chalk": "^5.6.2",
"commander": "^15.0.0",
"neverthrow": "^8.2.0",
"yaml": "^2.9.0",
},
},
"packages/core": {
"name": "@kitchen-md/core",
"version": "0.0.0",
"dependencies": {
"remark-frontmatter": "^5.0.0",
"remark-gfm": "^4.0.1",
"remark-parse": "^11.0.0",
"unified": "^11.0.5",
"yaml": "^2.9.0",
},
},
},
"packages": {
@@ -48,16 +60,188 @@
"@kitchen-md/core": ["@kitchen-md/core@workspace:packages/core"],
"@nodable/entities": ["@nodable/entities@3.0.0", "", {}, "sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw=="],
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.62.3", "", { "os": "linux", "cpu": "x64" }, "sha512-V4KtWtQfAFMU7+9/A/VDps/VI8CHd3cYz0L8sgJzz8qK7eY7wI4ruFD82UYIYvW9Z4DtlTfhQcsl4XyPHW5uSg=="],
"@types/debug": ["@types/debug@4.1.13", "", { "dependencies": { "@types/ms": "*" } }, "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw=="],
"@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="],
"@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
"@types/node": ["@types/node@26.1.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw=="],
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
"anynum": ["anynum@1.0.1", "", {}, "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A=="],
"bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="],
"bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
"bun2nix": ["bun2nix@2.1.2", "", { "dependencies": { "sade": "^1.8.1" }, "bin": { "bun2nix": "index.ts" } }, "sha512-0wx6Ar5ccrz4aSD5prbShwymjDEXFh7Bucxs+YrpAMa67TnVB95Hv8FV3oaQEbtOx6QGgIAyOmap6Y3WCRqetg=="],
"ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
"chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
"character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="],
"commander": ["commander@15.0.0", "", {}, "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="],
"dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
"devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="],
"escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="],
"extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
"fast-xml-builder": ["fast-xml-builder@1.3.0", "", { "dependencies": { "path-expression-matcher": "^1.6.2", "xml-naming": "^0.3.0" } }, "sha512-F74cZEdCvuw9P41GAC3rod4X04jjWGM1JPEv/GWSqFTWLsdyMSBMBMlm9Hk3GLBgLBbdBNY8yee0pQh2RBVESQ=="],
"fast-xml-parser": ["fast-xml-parser@5.10.1", "", { "dependencies": { "@nodable/entities": "^3.0.0", "fast-xml-builder": "^1.2.0", "is-unsafe": "^2.0.0", "path-expression-matcher": "^1.6.2", "strnum": "^2.4.1", "xml-naming": "^0.3.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-IEMIf7298kXuZSRFoGfMYrl7is8LpavODgbNz1cwIudv7KwVFnuU+UsMporfq6PD6aXSlawZlARiA3UywCTfMw=="],
"fault": ["fault@2.0.1", "", { "dependencies": { "format": "^0.2.0" } }, "sha512-WtySTkS4OKev5JtpHXnib4Gxiurzh5NCGvWrFaZ34m6JehfTUhKZvn9njTfw48t6JumVQOmrKqpmGcdwxnhqBQ=="],
"format": ["format@0.2.2", "", {}, "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww=="],
"is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="],
"is-unsafe": ["is-unsafe@2.0.0", "", {}, "sha512-2LdV822R+wmI86unXA93WCFpL6g+av8ynWk0nrHyJqGop5VoocYsSLFgN8jrfalT6iGeLNM4KXuVSsULP53kEA=="],
"longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="],
"markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="],
"mdast-util-find-and-replace": ["mdast-util-find-and-replace@3.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "escape-string-regexp": "^5.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg=="],
"mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.3", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q=="],
"mdast-util-frontmatter": ["mdast-util-frontmatter@2.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "escape-string-regexp": "^5.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "micromark-extension-frontmatter": "^2.0.0" } }, "sha512-LRqI9+wdgC25P0URIJY9vwocIzCcksduHQ9OF2joxQoyTNVduwLAFUzjoopuRJbJAReaKrNQKAZKL3uCMugWJA=="],
"mdast-util-gfm": ["mdast-util-gfm@3.1.0", "", { "dependencies": { "mdast-util-from-markdown": "^2.0.0", "mdast-util-gfm-autolink-literal": "^2.0.0", "mdast-util-gfm-footnote": "^2.0.0", "mdast-util-gfm-strikethrough": "^2.0.0", "mdast-util-gfm-table": "^2.0.0", "mdast-util-gfm-task-list-item": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ=="],
"mdast-util-gfm-autolink-literal": ["mdast-util-gfm-autolink-literal@2.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "ccount": "^2.0.0", "devlop": "^1.0.0", "mdast-util-find-and-replace": "^3.0.0", "micromark-util-character": "^2.0.0" } }, "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ=="],
"mdast-util-gfm-footnote": ["mdast-util-gfm-footnote@2.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0" } }, "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ=="],
"mdast-util-gfm-strikethrough": ["mdast-util-gfm-strikethrough@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg=="],
"mdast-util-gfm-table": ["mdast-util-gfm-table@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "markdown-table": "^3.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg=="],
"mdast-util-gfm-task-list-item": ["mdast-util-gfm-task-list-item@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ=="],
"mdast-util-phrasing": ["mdast-util-phrasing@4.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "unist-util-is": "^6.0.0" } }, "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w=="],
"mdast-util-to-markdown": ["mdast-util-to-markdown@2.1.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "longest-streak": "^3.0.0", "mdast-util-phrasing": "^4.0.0", "mdast-util-to-string": "^4.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "unist-util-visit": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA=="],
"mdast-util-to-string": ["mdast-util-to-string@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="],
"micromark": ["micromark@4.0.2", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="],
"micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="],
"micromark-extension-frontmatter": ["micromark-extension-frontmatter@2.0.0", "", { "dependencies": { "fault": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-C4AkuM3dA58cgZha7zVnuVxBhDsbttIMiytjgsM2XbHAB2faRVaHRle40558FBN+DJcrLNCoqG5mlrpdU4cRtg=="],
"micromark-extension-gfm": ["micromark-extension-gfm@3.0.0", "", { "dependencies": { "micromark-extension-gfm-autolink-literal": "^2.0.0", "micromark-extension-gfm-footnote": "^2.0.0", "micromark-extension-gfm-strikethrough": "^2.0.0", "micromark-extension-gfm-table": "^2.0.0", "micromark-extension-gfm-tagfilter": "^2.0.0", "micromark-extension-gfm-task-list-item": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w=="],
"micromark-extension-gfm-autolink-literal": ["micromark-extension-gfm-autolink-literal@2.1.0", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw=="],
"micromark-extension-gfm-footnote": ["micromark-extension-gfm-footnote@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw=="],
"micromark-extension-gfm-strikethrough": ["micromark-extension-gfm-strikethrough@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw=="],
"micromark-extension-gfm-table": ["micromark-extension-gfm-table@2.1.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg=="],
"micromark-extension-gfm-tagfilter": ["micromark-extension-gfm-tagfilter@2.0.0", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg=="],
"micromark-extension-gfm-task-list-item": ["micromark-extension-gfm-task-list-item@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw=="],
"micromark-factory-destination": ["micromark-factory-destination@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA=="],
"micromark-factory-label": ["micromark-factory-label@2.0.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg=="],
"micromark-factory-space": ["micromark-factory-space@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg=="],
"micromark-factory-title": ["micromark-factory-title@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw=="],
"micromark-factory-whitespace": ["micromark-factory-whitespace@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ=="],
"micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="],
"micromark-util-chunked": ["micromark-util-chunked@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA=="],
"micromark-util-classify-character": ["micromark-util-classify-character@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q=="],
"micromark-util-combine-extensions": ["micromark-util-combine-extensions@2.0.1", "", { "dependencies": { "micromark-util-chunked": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg=="],
"micromark-util-decode-numeric-character-reference": ["micromark-util-decode-numeric-character-reference@2.0.2", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw=="],
"micromark-util-decode-string": ["micromark-util-decode-string@2.0.1", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ=="],
"micromark-util-encode": ["micromark-util-encode@2.0.1", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="],
"micromark-util-html-tag-name": ["micromark-util-html-tag-name@2.0.1", "", {}, "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA=="],
"micromark-util-normalize-identifier": ["micromark-util-normalize-identifier@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q=="],
"micromark-util-resolve-all": ["micromark-util-resolve-all@2.0.1", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg=="],
"micromark-util-sanitize-uri": ["micromark-util-sanitize-uri@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ=="],
"micromark-util-subtokenize": ["micromark-util-subtokenize@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA=="],
"micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="],
"micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="],
"mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"neverthrow": ["neverthrow@8.2.0", "", { "optionalDependencies": { "@rollup/rollup-linux-x64-gnu": "^4.24.0" } }, "sha512-kOCT/1MCPAxY5iUV3wytNFUMUolzuwd/VF/1KCx7kf6CutrOsTie+84zTGTpgQycjvfLdBBdvBvFLqFD2c0wkQ=="],
"path-expression-matcher": ["path-expression-matcher@1.6.2", "", {}, "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ=="],
"remark-frontmatter": ["remark-frontmatter@5.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-frontmatter": "^2.0.0", "micromark-extension-frontmatter": "^2.0.0", "unified": "^11.0.0" } }, "sha512-XTFYvNASMe5iPN0719nPrdItC9aU0ssC4v14mH1BCi1u0n1gAocqcujWUrByftZTbLhRtiKRyjYTSIOcr69UVQ=="],
"remark-gfm": ["remark-gfm@4.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-gfm": "^3.0.0", "micromark-extension-gfm": "^3.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.0" } }, "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg=="],
"remark-parse": ["remark-parse@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "micromark-util-types": "^2.0.0", "unified": "^11.0.0" } }, "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA=="],
"remark-stringify": ["remark-stringify@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-to-markdown": "^2.0.0", "unified": "^11.0.0" } }, "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw=="],
"sade": ["sade@1.8.1", "", { "dependencies": { "mri": "^1.1.0" } }, "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A=="],
"strnum": ["strnum@2.4.1", "", { "dependencies": { "anynum": "^1.0.1" } }, "sha512-M9eUSMT2dCB2cTNPG7UYj6KuK7RJR2SN2+yCV/fTW3xzTCS6EaGZ5pSMgDIjB7r8zSfTGk+dvvn9rTjpVS9Mwg=="],
"trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="],
"undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="],
"unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="],
"unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="],
"unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="],
"unist-util-visit": ["unist-util-visit@5.1.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg=="],
"unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="],
"vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],
"vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="],
"xml-naming": ["xml-naming@0.3.0", "", {}, "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ=="],
"yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="],
"zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
}
}

344
bun.nix
View File

@@ -51,10 +51,42 @@
};
"@kitchen-md/bin" = copyPathToStore ./packages/bin;
"@kitchen-md/core" = copyPathToStore ./packages/core;
"@nodable/entities@3.0.0" = fetchurl {
url = "https://registry.npmjs.org/@nodable/entities/-/entities-3.0.0.tgz";
hash = "sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw==";
};
"@rollup/rollup-linux-x64-gnu@4.62.3" = fetchurl {
url = "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.3.tgz";
hash = "sha512-V4KtWtQfAFMU7+9/A/VDps/VI8CHd3cYz0L8sgJzz8qK7eY7wI4ruFD82UYIYvW9Z4DtlTfhQcsl4XyPHW5uSg==";
};
"@types/debug@4.1.13" = fetchurl {
url = "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz";
hash = "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==";
};
"@types/mdast@4.0.4" = fetchurl {
url = "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz";
hash = "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==";
};
"@types/ms@2.1.0" = fetchurl {
url = "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz";
hash = "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==";
};
"@types/node@26.1.1" = fetchurl {
url = "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz";
hash = "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==";
};
"@types/unist@3.0.3" = fetchurl {
url = "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz";
hash = "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==";
};
"anynum@1.0.1" = fetchurl {
url = "https://registry.npmjs.org/anynum/-/anynum-1.0.1.tgz";
hash = "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==";
};
"bail@2.0.2" = fetchurl {
url = "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz";
hash = "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==";
};
"bun-types@1.3.14" = fetchurl {
url = "https://registry.npmjs.org/bun-types/-/bun-types-1.3.14.tgz";
hash = "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ==";
@@ -63,16 +95,328 @@
url = "https://registry.npmjs.org/bun2nix/-/bun2nix-2.1.2.tgz";
hash = "sha512-0wx6Ar5ccrz4aSD5prbShwymjDEXFh7Bucxs+YrpAMa67TnVB95Hv8FV3oaQEbtOx6QGgIAyOmap6Y3WCRqetg==";
};
"ccount@2.0.1" = fetchurl {
url = "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz";
hash = "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==";
};
"chalk@5.6.2" = fetchurl {
url = "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz";
hash = "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==";
};
"character-entities@2.0.2" = fetchurl {
url = "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz";
hash = "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==";
};
"commander@15.0.0" = fetchurl {
url = "https://registry.npmjs.org/commander/-/commander-15.0.0.tgz";
hash = "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==";
};
"debug@4.4.3" = fetchurl {
url = "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz";
hash = "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==";
};
"decode-named-character-reference@1.3.0" = fetchurl {
url = "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz";
hash = "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==";
};
"dequal@2.0.3" = fetchurl {
url = "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz";
hash = "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==";
};
"devlop@1.1.0" = fetchurl {
url = "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz";
hash = "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==";
};
"escape-string-regexp@5.0.0" = fetchurl {
url = "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz";
hash = "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==";
};
"extend@3.0.2" = fetchurl {
url = "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz";
hash = "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==";
};
"fast-xml-builder@1.3.0" = fetchurl {
url = "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.3.0.tgz";
hash = "sha512-F74cZEdCvuw9P41GAC3rod4X04jjWGM1JPEv/GWSqFTWLsdyMSBMBMlm9Hk3GLBgLBbdBNY8yee0pQh2RBVESQ==";
};
"fast-xml-parser@5.10.1" = fetchurl {
url = "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.10.1.tgz";
hash = "sha512-IEMIf7298kXuZSRFoGfMYrl7is8LpavODgbNz1cwIudv7KwVFnuU+UsMporfq6PD6aXSlawZlARiA3UywCTfMw==";
};
"fault@2.0.1" = fetchurl {
url = "https://registry.npmjs.org/fault/-/fault-2.0.1.tgz";
hash = "sha512-WtySTkS4OKev5JtpHXnib4Gxiurzh5NCGvWrFaZ34m6JehfTUhKZvn9njTfw48t6JumVQOmrKqpmGcdwxnhqBQ==";
};
"format@0.2.2" = fetchurl {
url = "https://registry.npmjs.org/format/-/format-0.2.2.tgz";
hash = "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==";
};
"is-plain-obj@4.1.0" = fetchurl {
url = "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz";
hash = "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==";
};
"is-unsafe@2.0.0" = fetchurl {
url = "https://registry.npmjs.org/is-unsafe/-/is-unsafe-2.0.0.tgz";
hash = "sha512-2LdV822R+wmI86unXA93WCFpL6g+av8ynWk0nrHyJqGop5VoocYsSLFgN8jrfalT6iGeLNM4KXuVSsULP53kEA==";
};
"longest-streak@3.1.0" = fetchurl {
url = "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz";
hash = "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==";
};
"markdown-table@3.0.4" = fetchurl {
url = "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz";
hash = "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==";
};
"mdast-util-find-and-replace@3.0.2" = fetchurl {
url = "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz";
hash = "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==";
};
"mdast-util-from-markdown@2.0.3" = fetchurl {
url = "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz";
hash = "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==";
};
"mdast-util-frontmatter@2.0.1" = fetchurl {
url = "https://registry.npmjs.org/mdast-util-frontmatter/-/mdast-util-frontmatter-2.0.1.tgz";
hash = "sha512-LRqI9+wdgC25P0URIJY9vwocIzCcksduHQ9OF2joxQoyTNVduwLAFUzjoopuRJbJAReaKrNQKAZKL3uCMugWJA==";
};
"mdast-util-gfm-autolink-literal@2.0.1" = fetchurl {
url = "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz";
hash = "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==";
};
"mdast-util-gfm-footnote@2.1.0" = fetchurl {
url = "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz";
hash = "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==";
};
"mdast-util-gfm-strikethrough@2.0.0" = fetchurl {
url = "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz";
hash = "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==";
};
"mdast-util-gfm-table@2.0.0" = fetchurl {
url = "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz";
hash = "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==";
};
"mdast-util-gfm-task-list-item@2.0.0" = fetchurl {
url = "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz";
hash = "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==";
};
"mdast-util-gfm@3.1.0" = fetchurl {
url = "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz";
hash = "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==";
};
"mdast-util-phrasing@4.1.0" = fetchurl {
url = "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz";
hash = "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==";
};
"mdast-util-to-markdown@2.1.2" = fetchurl {
url = "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz";
hash = "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==";
};
"mdast-util-to-string@4.0.0" = fetchurl {
url = "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz";
hash = "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==";
};
"micromark-core-commonmark@2.0.3" = fetchurl {
url = "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz";
hash = "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==";
};
"micromark-extension-frontmatter@2.0.0" = fetchurl {
url = "https://registry.npmjs.org/micromark-extension-frontmatter/-/micromark-extension-frontmatter-2.0.0.tgz";
hash = "sha512-C4AkuM3dA58cgZha7zVnuVxBhDsbttIMiytjgsM2XbHAB2faRVaHRle40558FBN+DJcrLNCoqG5mlrpdU4cRtg==";
};
"micromark-extension-gfm-autolink-literal@2.1.0" = fetchurl {
url = "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz";
hash = "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==";
};
"micromark-extension-gfm-footnote@2.1.0" = fetchurl {
url = "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz";
hash = "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==";
};
"micromark-extension-gfm-strikethrough@2.1.0" = fetchurl {
url = "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz";
hash = "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==";
};
"micromark-extension-gfm-table@2.1.1" = fetchurl {
url = "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz";
hash = "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==";
};
"micromark-extension-gfm-tagfilter@2.0.0" = fetchurl {
url = "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz";
hash = "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==";
};
"micromark-extension-gfm-task-list-item@2.1.0" = fetchurl {
url = "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz";
hash = "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==";
};
"micromark-extension-gfm@3.0.0" = fetchurl {
url = "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz";
hash = "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==";
};
"micromark-factory-destination@2.0.1" = fetchurl {
url = "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz";
hash = "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==";
};
"micromark-factory-label@2.0.1" = fetchurl {
url = "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz";
hash = "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==";
};
"micromark-factory-space@2.0.1" = fetchurl {
url = "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz";
hash = "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==";
};
"micromark-factory-title@2.0.1" = fetchurl {
url = "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz";
hash = "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==";
};
"micromark-factory-whitespace@2.0.1" = fetchurl {
url = "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz";
hash = "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==";
};
"micromark-util-character@2.1.1" = fetchurl {
url = "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz";
hash = "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==";
};
"micromark-util-chunked@2.0.1" = fetchurl {
url = "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz";
hash = "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==";
};
"micromark-util-classify-character@2.0.1" = fetchurl {
url = "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz";
hash = "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==";
};
"micromark-util-combine-extensions@2.0.1" = fetchurl {
url = "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz";
hash = "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==";
};
"micromark-util-decode-numeric-character-reference@2.0.2" = fetchurl {
url = "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz";
hash = "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==";
};
"micromark-util-decode-string@2.0.1" = fetchurl {
url = "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz";
hash = "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==";
};
"micromark-util-encode@2.0.1" = fetchurl {
url = "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz";
hash = "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==";
};
"micromark-util-html-tag-name@2.0.1" = fetchurl {
url = "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz";
hash = "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==";
};
"micromark-util-normalize-identifier@2.0.1" = fetchurl {
url = "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz";
hash = "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==";
};
"micromark-util-resolve-all@2.0.1" = fetchurl {
url = "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz";
hash = "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==";
};
"micromark-util-sanitize-uri@2.0.1" = fetchurl {
url = "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz";
hash = "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==";
};
"micromark-util-subtokenize@2.1.0" = fetchurl {
url = "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz";
hash = "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==";
};
"micromark-util-symbol@2.0.1" = fetchurl {
url = "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz";
hash = "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==";
};
"micromark-util-types@2.0.2" = fetchurl {
url = "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz";
hash = "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==";
};
"micromark@4.0.2" = fetchurl {
url = "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz";
hash = "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==";
};
"mri@1.2.0" = fetchurl {
url = "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz";
hash = "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==";
};
"ms@2.1.3" = fetchurl {
url = "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz";
hash = "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==";
};
"neverthrow@8.2.0" = fetchurl {
url = "https://registry.npmjs.org/neverthrow/-/neverthrow-8.2.0.tgz";
hash = "sha512-kOCT/1MCPAxY5iUV3wytNFUMUolzuwd/VF/1KCx7kf6CutrOsTie+84zTGTpgQycjvfLdBBdvBvFLqFD2c0wkQ==";
};
"path-expression-matcher@1.6.2" = fetchurl {
url = "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.2.tgz";
hash = "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==";
};
"remark-frontmatter@5.0.0" = fetchurl {
url = "https://registry.npmjs.org/remark-frontmatter/-/remark-frontmatter-5.0.0.tgz";
hash = "sha512-XTFYvNASMe5iPN0719nPrdItC9aU0ssC4v14mH1BCi1u0n1gAocqcujWUrByftZTbLhRtiKRyjYTSIOcr69UVQ==";
};
"remark-gfm@4.0.1" = fetchurl {
url = "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz";
hash = "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==";
};
"remark-parse@11.0.0" = fetchurl {
url = "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz";
hash = "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==";
};
"remark-stringify@11.0.0" = fetchurl {
url = "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz";
hash = "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==";
};
"sade@1.8.1" = fetchurl {
url = "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz";
hash = "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==";
};
"strnum@2.4.1" = fetchurl {
url = "https://registry.npmjs.org/strnum/-/strnum-2.4.1.tgz";
hash = "sha512-M9eUSMT2dCB2cTNPG7UYj6KuK7RJR2SN2+yCV/fTW3xzTCS6EaGZ5pSMgDIjB7r8zSfTGk+dvvn9rTjpVS9Mwg==";
};
"trough@2.2.0" = fetchurl {
url = "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz";
hash = "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==";
};
"undici-types@8.3.0" = fetchurl {
url = "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz";
hash = "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==";
};
"unified@11.0.5" = fetchurl {
url = "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz";
hash = "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==";
};
"unist-util-is@6.0.1" = fetchurl {
url = "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz";
hash = "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==";
};
"unist-util-stringify-position@4.0.0" = fetchurl {
url = "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz";
hash = "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==";
};
"unist-util-visit-parents@6.0.2" = fetchurl {
url = "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz";
hash = "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==";
};
"unist-util-visit@5.1.0" = fetchurl {
url = "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz";
hash = "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==";
};
"vfile-message@4.0.3" = fetchurl {
url = "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz";
hash = "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==";
};
"vfile@6.0.3" = fetchurl {
url = "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz";
hash = "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==";
};
"xml-naming@0.3.0" = fetchurl {
url = "https://registry.npmjs.org/xml-naming/-/xml-naming-0.3.0.tgz";
hash = "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==";
};
"yaml@2.9.0" = fetchurl {
url = "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz";
hash = "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==";
};
"zwitch@2.0.4" = fetchurl {
url = "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz";
hash = "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==";
};
}

6
bunfig.toml Normal file
View File

@@ -0,0 +1,6 @@
[test]
# Coverage is opt-in via `bun test --coverage` (see the test:coverage script).
# These settings only take effect when that flag is passed.
coverageReporter = ["text", "lcov"]
coverageDir = "coverage"
coverageSkipTestFiles = true

22
fixtures/prose.md Normal file
View File

@@ -0,0 +1,22 @@
---
title: Buttered Toast
servings: 2
tags: [breakfast, simple]
---
# Buttered Toast
The simplest breakfast, and a good way to smoke-test the renderer.
Everything here is plain prose — no lists, code, or annotations yet.
## Method
Toast the bread until golden on both sides.
### Timing
Spread the butter while the toast is still warm so it soaks in.
## Serving
Cut into triangles and serve at once.

View File

@@ -93,11 +93,6 @@
doCheck = true;
installPhase = "touch $out";
};
smoke = pkgs.runCommand "kitchen-smoke" { } ''
${kitchen}/bin/kitchen --help
touch $out
'';
};
};
};

View File

@@ -8,6 +8,8 @@
],
"scripts": {
"test": "bun test",
"test:coverage": "bun test --coverage",
"test:report": "bun run scripts/test-report.ts",
"lint": "biome check .",
"format": "biome check --write .",
"postinstall": "bun2nix -o bun.nix"
@@ -15,6 +17,7 @@
"devDependencies": {
"@biomejs/biome": "^2.5.3",
"bun-types": "latest",
"bun2nix": "^2.1.2"
"bun2nix": "^2.1.2",
"fast-xml-parser": "^5.10.1"
}
}

View File

@@ -11,6 +11,10 @@
"build": "bun build --compile ./src/index.ts --outfile kitchen"
},
"dependencies": {
"@kitchen-md/core": "workspace:*"
"@kitchen-md/core": "workspace:*",
"chalk": "^5.6.2",
"commander": "^15.0.0",
"neverthrow": "^8.2.0",
"yaml": "^2.9.0"
}
}

View File

@@ -0,0 +1,65 @@
import { describe, expect, test } from "bun:test";
import { join } from "node:path";
const repoRoot = join(import.meta.dir, "..", "..", "..");
const entry = join(repoRoot, "packages", "bin", "src", "index.ts");
async function runKitchen(args: string[]) {
const proc = Bun.spawn(["bun", entry, ...args], {
cwd: repoRoot,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
]);
const exitCode = await proc.exited;
return { exitCode, stdout, stderr };
}
describe("kitchen CLI (e2e)", () => {
test("view renders a recipe file to stdout and exits 0", async () => {
const { exitCode, stdout, stderr } = await runKitchen(["view", "fixtures/prose.md"]);
const out = Bun.stripANSI(stdout);
expect(exitCode).toBe(0);
expect(stderr).toBe("");
expect(out).toContain("Buttered Toast");
expect(out).toContain("Method");
expect(out).toContain("Serving");
expect(out).toContain("Cut into triangles and serve at once.");
});
test("view preserves document order in the output", async () => {
const { stdout } = await runKitchen(["view", "fixtures/prose.md"]);
const out = Bun.stripANSI(stdout);
expect(out.indexOf("Method")).toBeLessThan(out.indexOf("Serving"));
});
test("ANSI styling is suppressed when stdout is not a TTY (piped)", async () => {
const { stdout } = await runKitchen(["view", "fixtures/prose.md"]);
expect(stdout).not.toContain("\x1b");
});
test("a nonexistent file prints a human-readable error to stderr and exits 1", async () => {
const { exitCode, stdout, stderr } = await runKitchen(["view", "does/not/exist.md"]);
expect(exitCode).toBe(1);
expect(stdout).toBe("");
expect(stderr).toContain("cannot read");
expect(stderr).toContain("does/not/exist.md");
expect(stderr.startsWith("kitchen:")).toBe(true);
});
test("a missing file argument prints usage to stderr and exits 1", async () => {
const { exitCode, stderr } = await runKitchen(["view"]);
expect(exitCode).toBe(1);
expect(stderr).not.toBe("");
expect(stderr.toLowerCase()).toContain("missing required argument");
});
test("--help lists the view command and exits 0", async () => {
const { exitCode, stdout } = await runKitchen(["--help"]);
expect(exitCode).toBe(0);
expect(stdout).toContain("view");
});
});

View File

@@ -1 +1,54 @@
// CLI entry point
#!/usr/bin/env bun
import { readFileSync } from "node:fs";
import { parse } from "@kitchen-md/core";
import { Command } from "commander";
import { err, ok, type Result } from "neverthrow";
import { render } from "./render.ts";
export type ViewError = { tag: "read-failed"; path: string; cause: string };
// The union of every error a command can surface to the boundary; new fallible commands add their variants here.
export type CliError = ViewError;
export function viewFile(path: string): Result<string, ViewError> {
return readFile(path).map((content) => render(parse(content)));
}
export function formatError(error: CliError): string {
switch (error.tag) {
case "read-failed":
return `cannot read '${error.path}': ${error.cause}`;
}
}
// The one place a throwing API is turned into a Result; nothing above this leaks exceptions.
function readFile(path: string): Result<string, ViewError> {
try {
return ok(readFileSync(path, "utf8"));
} catch (error) {
const cause = error instanceof Error ? error.message : String(error);
return err({ tag: "read-failed", path, cause });
}
}
const program = new Command();
program.name("kitchen").description("Read and view KitchenMD Recipe Files");
program
.command("view")
.description("Render a Recipe File to the terminal")
.argument("<file>", "path to a Recipe File")
.action((file: string) => {
viewFile(file).match(
(output) => process.stdout.write(output),
(error) => {
process.stderr.write(`kitchen: ${formatError(error)}\n`);
process.exit(1);
},
);
});
if (import.meta.main) {
program.parse();
}

View File

@@ -1,6 +1,86 @@
import { describe, test } from "bun:test";
import { afterEach, describe, expect, test } from "bun:test";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { formatError, type ViewError, viewFile } from "./index.ts";
describe("cli", () => {
test.todo("exits with non-zero code when no file argument is given");
test.todo("exits with non-zero code when file does not exist");
describe("viewFile", () => {
let dir: string | undefined;
afterEach(() => {
if (dir) {
rmSync(dir, { recursive: true, force: true });
dir = undefined;
}
});
const writeRecipe = (name: string, content: string): string => {
dir = mkdtempSync(join(tmpdir(), "kitchen-view-"));
const path = join(dir, name);
writeFileSync(path, content);
return path;
};
test("viewFile returns ok with the rendered document for a readable file", () => {
const path = writeRecipe(
"toast.md",
`# Buttered Toast
Toast the bread until golden.
`,
);
const result = viewFile(path);
expect(result.isOk()).toBe(true);
const rendered = Bun.stripANSI(result._unsafeUnwrap());
expect(rendered).toContain("Buttered Toast");
expect(rendered).toContain("Toast the bread until golden.");
});
test("viewFile returns ok and passes frontmatter through to the rendered output", () => {
const path = writeRecipe(
"toast.md",
`---
title: Buttered Toast
servings: 2
---
# Buttered Toast
`,
);
const result = viewFile(path);
expect(result.isOk()).toBe(true);
const rendered = Bun.stripANSI(result._unsafeUnwrap());
expect(rendered).toContain("title: Buttered Toast");
expect(rendered).toContain("servings: 2");
});
test("viewFile returns a read-failed error for a nonexistent path", () => {
dir = mkdtempSync(join(tmpdir(), "kitchen-view-"));
const missing = join(dir, "does-not-exist.md");
const result = viewFile(missing);
expect(result.isErr()).toBe(true);
const e = result._unsafeUnwrapErr();
expect(e.tag).toBe("read-failed");
expect(e.path).toBe(missing);
expect(typeof e.cause).toBe("string");
expect(e.cause.length).toBeGreaterThan(0);
});
});
describe("formatError", () => {
test("formatError renders a read-failed error as a human-readable line", () => {
const error: ViewError = {
tag: "read-failed",
path: "/nope/recipe.md",
cause: "no such file",
};
expect(formatError(error)).toBe("cannot read '/nope/recipe.md': no such file");
});
});

View File

@@ -1,5 +0,0 @@
import { describe, test } from "bun:test";
describe("cli — integration", () => {
test.todo("invokes core parser and produces output for a real fixture file");
});

107
packages/bin/src/render.ts Normal file
View File

@@ -0,0 +1,107 @@
import type {
Block,
BlockquoteBlock,
DocumentAST,
Frontmatter,
HeadingBlock,
InlineNode,
ListBlock,
ListItemBlock,
} from "@kitchen-md/core";
import chalk from "chalk";
import { stringify as stringifyYaml } from "yaml";
const RULE = "─".repeat(40);
export function render(ast: DocumentAST): string {
const body = ast.blocks.map(renderBlock).join("");
return renderFrontmatter(ast.frontmatter) + body;
}
function renderFrontmatter(frontmatter: Frontmatter): string {
if (Object.keys(frontmatter).length === 0) {
return "";
}
return `${stringifyYaml(frontmatter)}${chalk.dim(RULE)}\n`;
}
function renderBlock(block: Block): string {
switch (block.type) {
case "heading":
return `${styleHeading(block.level)(renderInline(block.children))}\n`;
case "paragraph":
return `${renderInline(block.children)}\n\n`;
case "list":
return renderList(block);
case "blockquote":
return renderBlockquote(block);
case "code":
return `${chalk.dim(block.value)}\n\n`;
case "thematicBreak":
return `${chalk.dim(RULE)}\n\n`;
case "raw":
return `${block.value}\n\n`;
}
}
// Each level gets a distinct style, tapering from bold at level 1 toward dim at level 6.
function styleHeading(level: HeadingBlock["level"]): (text: string) => string {
switch (level) {
case 1:
return chalk.bold.underline;
case 2:
return chalk.bold;
case 3:
return chalk.bold.dim;
case 4:
return chalk.dim.underline;
case 5:
return chalk.dim;
case 6:
return chalk.dim.italic;
}
}
function renderList(block: ListBlock): string {
const lines = block.items.map((item, index) => {
const marker = block.ordered ? `${index + 1}. ` : "• ";
return marker + renderItemContent(item);
});
return `${lines.join("\n")}\n\n`;
}
// The item's child blocks, collapsed onto the marker line without their own
// trailing block spacing.
function renderItemContent(item: ListItemBlock): string {
return item.children.map(renderBlock).join("").trimEnd();
}
function renderBlockquote(block: BlockquoteBlock): string {
const inner = block.children.map(renderBlock).join("").trimEnd();
const quoted = inner
.split("\n")
.map((line) => chalk.dim("│ ") + line)
.join("\n");
return `${quoted}\n\n`;
}
function renderInline(nodes: InlineNode[]): string {
return nodes.map(renderInlineNode).join("");
}
function renderInlineNode(node: InlineNode): string {
switch (node.type) {
case "text":
return node.value;
case "emphasis":
return chalk.italic(renderInline(node.children));
case "strong":
return chalk.bold(renderInline(node.children));
case "codeSpan":
return chalk.inverse(node.value);
case "link":
return chalk.underline(renderInline(node.children));
case "rawInline":
return node.value;
}
}

View File

@@ -0,0 +1,231 @@
import { describe, expect, test } from "bun:test";
import type { DocumentAST, HeadingBlock } from "@kitchen-md/core";
import chalk from "chalk";
import { render } from "./render.ts";
describe("render", () => {
test("a heading renders its text followed by a single newline", () => {
const ast: DocumentAST = {
frontmatter: {},
blocks: [{ type: "heading", level: 2, children: [{ type: "text", value: "Method" }] }],
diagnostics: [],
};
expect(Bun.stripANSI(render(ast))).toBe("Method\n");
});
test("a paragraph renders its text followed by a blank line", () => {
const ast: DocumentAST = {
frontmatter: {},
blocks: [{ type: "paragraph", children: [{ type: "text", value: "Toast the bread." }] }],
diagnostics: [],
};
expect(Bun.stripANSI(render(ast))).toBe("Toast the bread.\n\n");
});
test("frontmatter renders as YAML before the body, followed by a separator rule", () => {
const ast: DocumentAST = {
frontmatter: { title: "Buttered Toast", servings: 2 },
blocks: [
{ type: "heading", level: 1, children: [{ type: "text", value: "Buttered Toast" }] },
],
diagnostics: [],
};
const stripped = Bun.stripANSI(render(ast));
expect(stripped).toContain("title: Buttered Toast");
expect(stripped).toContain("servings: 2");
expect(stripped).toContain("──────");
const titleIndex = stripped.indexOf("title: Buttered Toast");
const separatorIndex = stripped.indexOf("──────");
const bodyIndex = stripped.lastIndexOf("Buttered Toast");
expect(titleIndex).toBeLessThan(separatorIndex);
expect(separatorIndex).toBeLessThan(bodyIndex);
});
test("no frontmatter emits neither YAML nor a separator", () => {
const ast: DocumentAST = {
frontmatter: {},
blocks: [{ type: "paragraph", children: [{ type: "text", value: "Body only." }] }],
diagnostics: [],
};
const stripped = Bun.stripANSI(render(ast));
expect(stripped).toBe("Body only.\n\n");
expect(stripped).not.toContain("─");
});
test("blocks render in document order", () => {
const ast: DocumentAST = {
frontmatter: {},
blocks: [
{ type: "heading", level: 1, children: [{ type: "text", value: "Title" }] },
{ type: "paragraph", children: [{ type: "text", value: "First para." }] },
{ type: "heading", level: 2, children: [{ type: "text", value: "Next" }] },
],
diagnostics: [],
};
const stripped = Bun.stripANSI(render(ast));
expect(stripped.indexOf("Title")).toBeLessThan(stripped.indexOf("First para."));
expect(stripped.indexOf("First para.")).toBeLessThan(stripped.indexOf("Next"));
});
test("a heading renders its text at every level 16", () => {
const cases: [HeadingBlock["level"], string][] = [
[1, "Level One"],
[2, "Level Two"],
[3, "Level Three"],
[4, "Level Four"],
[5, "Level Five"],
[6, "Level Six"],
];
for (const [level, title] of cases) {
const ast: DocumentAST = {
frontmatter: {},
blocks: [{ type: "heading", level, children: [{ type: "text", value: title }] }],
diagnostics: [],
};
expect(Bun.stripANSI(render(ast))).toBe(`${title}\n`);
}
});
const bodyOf = (block: DocumentAST["blocks"][number]): string =>
Bun.stripANSI(render({ frontmatter: {}, blocks: [block], diagnostics: [] }));
test("an unordered list renders one bulleted item per line", () => {
const stripped = bodyOf({
type: "list",
ordered: false,
items: [
{
type: "listItem",
children: [{ type: "paragraph", children: [{ type: "text", value: "Flour" }] }],
},
{
type: "listItem",
children: [{ type: "paragraph", children: [{ type: "text", value: "Sugar" }] }],
},
],
});
expect(stripped).toBe("• Flour\n• Sugar\n\n");
});
test("an ordered list renders sequential numbers, one item per line", () => {
const stripped = bodyOf({
type: "list",
ordered: true,
items: [
{
type: "listItem",
children: [{ type: "paragraph", children: [{ type: "text", value: "Mix" }] }],
},
{
type: "listItem",
children: [{ type: "paragraph", children: [{ type: "text", value: "Bake" }] }],
},
],
});
expect(stripped).toBe("1. Mix\n2. Bake\n\n");
});
test("a code block renders its literal text with no highlighting", () => {
const stripped = bodyOf({ type: "code", lang: "js", value: "const x = 1;" });
expect(stripped).toContain("const x = 1;");
});
test("a thematic break renders a horizontal rule", () => {
const stripped = bodyOf({ type: "thematicBreak" });
expect(stripped).toContain("──────");
});
test("a blockquote renders its inner text", () => {
const stripped = bodyOf({
type: "blockquote",
children: [{ type: "paragraph", children: [{ type: "text", value: "Take care." }] }],
});
expect(stripped).toContain("Take care.");
});
test("a raw block renders its verbatim value", () => {
const table = "| a | b |\n| - | - |";
const stripped = bodyOf({ type: "raw", value: table });
expect(stripped).toContain(table);
});
test("emphasis, strong, code span, and links render their inline content", () => {
const stripped = bodyOf({
type: "paragraph",
children: [
{ type: "emphasis", children: [{ type: "text", value: "soft" }] },
{ type: "text", value: " " },
{ type: "strong", children: [{ type: "text", value: "hard" }] },
{ type: "text", value: " " },
{ type: "codeSpan", value: "code" },
{ type: "text", value: " " },
{ type: "link", href: "https://example.com", children: [{ type: "text", value: "docs" }] },
],
});
expect(stripped).toBe("soft hard code docs\n\n");
expect(stripped).not.toContain("https://example.com");
});
test("emphasis, strong, and code span each carry styling distinct from plain text", () => {
const previousLevel = chalk.level;
chalk.level = 1;
try {
const styled = (block: DocumentAST["blocks"][number]): string =>
render({ frontmatter: {}, blocks: [block], diagnostics: [] });
const emphasis = styled({
type: "paragraph",
children: [{ type: "emphasis", children: [{ type: "text", value: "x" }] }],
});
const strong = styled({
type: "paragraph",
children: [{ type: "strong", children: [{ type: "text", value: "x" }] }],
});
const codeSpan = styled({ type: "paragraph", children: [{ type: "codeSpan", value: "x" }] });
// Each carries ANSI styling, so the raw string differs from the stripped one.
expect(emphasis).not.toBe(Bun.stripANSI(emphasis));
expect(strong).not.toBe(Bun.stripANSI(strong));
expect(codeSpan).not.toBe(Bun.stripANSI(codeSpan));
// The three styles are mutually distinct.
expect(emphasis).not.toBe(strong);
expect(strong).not.toBe(codeSpan);
expect(emphasis).not.toBe(codeSpan);
} finally {
chalk.level = previousLevel;
}
});
test("a rawInline node renders its verbatim value", () => {
const stripped = bodyOf({
type: "paragraph",
children: [
{ type: "text", value: "a " },
{ type: "rawInline", value: "~~b~~" },
{ type: "text", value: " c" },
],
});
expect(stripped).toBe("a ~~b~~ c\n\n");
});
});

View File

@@ -1,6 +0,0 @@
import { describe, test } from "bun:test";
describe("smoke", () => {
test.todo("kitchen --help exits with code 0");
test.todo("kitchen parse <fixture> outputs structured JSON covering all annotation types");
});

View File

@@ -8,5 +8,12 @@
},
"scripts": {
"test": "bun test"
},
"dependencies": {
"remark-frontmatter": "^5.0.0",
"remark-gfm": "^4.0.1",
"remark-parse": "^11.0.0",
"unified": "^11.0.5",
"yaml": "^2.9.0"
}
}

View File

@@ -1 +1,2 @@
export {};
export { parse } from "./parse.ts";
export type * from "./types.ts";

View File

@@ -1,86 +0,0 @@
import { describe, test } from "bun:test";
describe("parser", () => {
describe("frontmatter", () => {
test.todo("parses frontmatter fields as-is");
test.todo("returns an empty object for empty frontmatter");
test.todo("returns an empty object when there is no frontmatter");
test.todo(
"does not throw on malformed frontmatter: frontmatter is {}, body still parses, and an invalid-frontmatter diagnostic preserves the raw YAML",
);
});
describe("blocks", () => {
test.todo("parses headings at every level (1-6)");
test.todo("parses paragraphs with typed inline nodes");
test.todo("parses an ordered list");
test.todo("parses an unordered list");
test.todo("models a list item as a container wrapping a paragraph, not a bare inline array");
test.todo("parses a code block and does not annotate its content");
test.todo("parses a thematic break");
test.todo("parses a blockquote as a container block");
test.todo("parses a callout (> [!note]) as an ordinary blockquote");
});
describe("inline nodes", () => {
test.todo("parses plain text");
test.todo("parses emphasis");
test.todo("parses strong");
test.todo("parses a code span and does not annotate its content");
test.todo("parses a link with href and inline content");
test.todo("parses a wikilink");
test.todo("parses a wikilink with an anchor");
test.todo("parses a wikilink with a display alias");
test.todo("parses a transclusion");
test.todo("parses a transclusion with a display alias");
});
describe("raw fallbacks", () => {
test.todo(
"preserves an unmodelled block (e.g. a GFM table) as a RawBlock with verbatim source",
);
test.todo(
"preserves an unmodelled inline (e.g. strikethrough) as a RawInline with verbatim source",
);
});
describe("ingredient annotations", () => {
test.todo("extracts ingredient name, quantity, and unit");
test.todo("extracts multi-word ingredient name");
test.todo("extracts ingredient with a unit-less quantity");
test.todo("extracts ingredient with no quantity");
});
describe("cookware annotations", () => {
test.todo("extracts cookware with quantity and unit");
test.todo("extracts cookware with no quantity");
test.todo("extracts multi-word cookware name");
});
describe("timer annotations", () => {
test.todo("extracts timer as a single value");
test.todo("extracts timer as a range");
test.todo("normalises timer unit aliases to canonical form");
test.todo("matches timer units case-insensitively (~5 Mins -> min)");
});
describe("unit normalisation", () => {
test.todo("normalises a known alias to canonical (grams -> g)");
test.todo("matches unit aliases case-insensitively (Tbsp -> tbsp)");
test.todo("normalises a multi-word alias (fluid ounces -> fl oz)");
test.todo("passes an unknown unit through verbatim");
});
describe("annotation scope", () => {
test.todo("captures annotations embedded mid-sentence");
test.todo("captures annotations inside a container (blockquote or list item)");
test.todo("does not extract annotations inside a code span");
test.todo("does not extract annotations inside a code block");
});
describe("transclusion", () => {
test.todo("passes a Step Reference anchor through as-is");
});
test.todo("standard Markdown elements pass through without interference");
});

View File

@@ -1,8 +0,0 @@
import { describe, test } from "bun:test";
describe("parser — integration", () => {
test.todo("parses the primary fixture into the complete Document AST");
test.todo("extracts every annotation type from the primary fixture");
test.todo("extracts an annotation from inside the fixture's blockquote");
test.todo("normalises the fixture's non-canonical unit (tablespoons -> tbsp)");
});

100
packages/core/src/parse.ts Normal file
View File

@@ -0,0 +1,100 @@
import type { ListItem, Node, PhrasingContent, Root, RootContent } from "mdast";
import remarkFrontmatter from "remark-frontmatter";
import remarkGfm from "remark-gfm";
import remarkParse from "remark-parse";
import { unified } from "unified";
import { parse as parseYaml } from "yaml";
import type { Block, DocumentAST, Frontmatter, InlineNode, ListItemBlock } from "./types.ts";
const processor = unified().use(remarkParse).use(remarkFrontmatter).use(remarkGfm);
export function parse(input: string): DocumentAST {
const tree = processor.parse(input);
const frontmatter = extractFrontmatter(tree);
const blocks = tree.children.flatMap((node) => translateBlock(node, input));
return { frontmatter, blocks, diagnostics: [] };
}
function extractFrontmatter(tree: Root): Frontmatter {
const yamlNode = tree.children.find((node) => node.type === "yaml");
if (!yamlNode) {
return {};
}
const data = parseYaml(yamlNode.value);
if (data !== null && typeof data === "object" && !Array.isArray(data)) {
return data as Frontmatter;
}
return {};
}
function translateBlock(node: RootContent, input: string): Block[] {
switch (node.type) {
case "heading":
return [
{ type: "heading", level: node.depth, children: translateInline(node.children, input) },
];
case "paragraph":
return [{ type: "paragraph", children: translateInline(node.children, input) }];
case "list":
return [
{
type: "list",
ordered: node.ordered ?? false,
items: node.children.map((item) => translateListItem(item, input)),
},
];
case "blockquote":
return [
{
type: "blockquote",
children: node.children.flatMap((child) => translateBlock(child, input)),
},
];
case "code":
return [{ type: "code", ...(node.lang ? { lang: node.lang } : {}), value: node.value }];
case "thematicBreak":
return [{ type: "thematicBreak" }];
// Frontmatter is captured separately and must not double as a block.
case "yaml":
return [];
default:
return [{ type: "raw", value: slice(node, input) }];
}
}
function translateListItem(item: ListItem, input: string): ListItemBlock {
return {
type: "listItem",
children: item.children.flatMap((child) => translateBlock(child, input)),
};
}
function translateInline(nodes: PhrasingContent[], input: string): InlineNode[] {
return nodes.flatMap((node): InlineNode[] => {
switch (node.type) {
case "text":
return [{ type: "text", value: node.value }];
case "emphasis":
return [{ type: "emphasis", children: translateInline(node.children, input) }];
case "strong":
return [{ type: "strong", children: translateInline(node.children, input) }];
case "inlineCode":
return [{ type: "codeSpan", value: node.value }];
case "link":
return [{ type: "link", href: node.url, children: translateInline(node.children, input) }];
default:
return [{ type: "rawInline", value: slice(node, input) }];
}
});
}
// Verbatim source for an unmodelled node, taken by position so it round-trips
// byte-for-byte rather than being re-stringified through remark.
function slice(node: Node, input: string): string {
const start = node.position?.start.offset;
const end = node.position?.end.offset;
if (start === undefined || end === undefined) {
return "";
}
return input.slice(start, end);
}

View File

@@ -0,0 +1,263 @@
import { describe, expect, test } from "bun:test";
import { parse } from "@kitchen-md/core";
describe("parse", () => {
test("frontmatter passthrough — arbitrary fields become a plain object", () => {
const input = `---
title: Buttered Toast
servings: 2
tags: [breakfast, simple]
---
# Buttered Toast
`;
const result = parse(input);
expect(result.frontmatter).toEqual({
title: "Buttered Toast",
servings: 2,
tags: ["breakfast", "simple"],
});
});
test("frontmatter is an empty object when absent", () => {
const result = parse("# Just a heading");
expect(result.frontmatter).toEqual({});
});
test("frontmatter is an empty object when the block is empty", () => {
const input = `---
---
# Heading
`;
const result = parse(input);
expect(result.frontmatter).toEqual({});
});
test("headings are modelled at every level 16 with TextNode children", () => {
const cases: [string, 1 | 2 | 3 | 4 | 5 | 6, string][] = [
["# Level One", 1, "Level One"],
["## Level Two", 2, "Level Two"],
["### Level Three", 3, "Level Three"],
["#### Level Four", 4, "Level Four"],
["##### Level Five", 5, "Level Five"],
["###### Level Six", 6, "Level Six"],
];
for (const [markdown, level, text] of cases) {
const result = parse(markdown);
expect(result.blocks).toEqual([
{
type: "heading",
level,
children: [{ type: "text", value: text }],
},
]);
}
});
test("a paragraph is a ParagraphBlock with TextNode content", () => {
const result = parse("Just some plain prose.");
expect(result.blocks).toEqual([
{
type: "paragraph",
children: [{ type: "text", value: "Just some plain prose." }],
},
]);
});
test("blocks are flat and in document order — a heading is a sibling of the following paragraph", () => {
const input = `# Title
A paragraph under it.
`;
const result = parse(input);
expect(result.blocks).toEqual([
{
type: "heading",
level: 1,
children: [{ type: "text", value: "Title" }],
},
{
type: "paragraph",
children: [{ type: "text", value: "A paragraph under it." }],
},
]);
});
test("diagnostics are empty in the normal case", () => {
const result = parse("# Ok");
expect(result.diagnostics).toEqual([]);
});
test("an unordered list is a ListBlock whose items wrap their child blocks", () => {
const result = parse("- First\n- Second\n");
expect(result.blocks).toEqual([
{
type: "list",
ordered: false,
items: [
{
type: "listItem",
children: [{ type: "paragraph", children: [{ type: "text", value: "First" }] }],
},
{
type: "listItem",
children: [{ type: "paragraph", children: [{ type: "text", value: "Second" }] }],
},
],
},
]);
});
test("an ordered list carries the ordered flag", () => {
const result = parse("1. One\n2. Two\n");
expect(result.blocks).toEqual([
{
type: "list",
ordered: true,
items: [
{
type: "listItem",
children: [{ type: "paragraph", children: [{ type: "text", value: "One" }] }],
},
{
type: "listItem",
children: [{ type: "paragraph", children: [{ type: "text", value: "Two" }] }],
},
],
},
]);
});
test("a list item is a container of blocks, not a bare inline array", () => {
const result = parse("- Just text\n");
const list = result.blocks[0];
if (list?.type !== "list") throw new Error("expected a list block");
expect(list.items[0]).toEqual({
type: "listItem",
children: [{ type: "paragraph", children: [{ type: "text", value: "Just text" }] }],
});
});
test("a blockquote is a container holding blocks", () => {
const result = parse("> Quoted prose.\n");
expect(result.blocks).toEqual([
{
type: "blockquote",
children: [{ type: "paragraph", children: [{ type: "text", value: "Quoted prose." }] }],
},
]);
});
test("an OFM callout parses as an ordinary blockquote with its text preserved", () => {
const result = parse("> [!note]\n> Remember this.\n");
const quote = result.blocks[0];
if (quote?.type !== "blockquote") throw new Error("expected a blockquote block");
const rendered = JSON.stringify(quote);
expect(rendered).toContain("[!note]");
expect(rendered).toContain("Remember this.");
});
test("a fenced code block carries its language and literal text", () => {
const result = parse("```js\nconst x = @sugar{1};\n```\n");
expect(result.blocks).toEqual([{ type: "code", lang: "js", value: "const x = @sugar{1};" }]);
});
test("a code block without a language has no lang", () => {
const result = parse("```\nplain text\n```\n");
expect(result.blocks).toEqual([{ type: "code", value: "plain text" }]);
});
test("a thematic break is modelled", () => {
const result = parse("---\n");
expect(result.blocks).toEqual([{ type: "thematicBreak" }]);
});
test("emphasis, strong, and code-span inlines are modelled", () => {
const result = parse("*em* **strong** `code`\n");
const para = result.blocks[0];
if (para?.type !== "paragraph") throw new Error("expected a paragraph block");
expect(para.children).toEqual([
{ type: "emphasis", children: [{ type: "text", value: "em" }] },
{ type: "text", value: " " },
{ type: "strong", children: [{ type: "text", value: "strong" }] },
{ type: "text", value: " " },
{ type: "codeSpan", value: "code" },
]);
});
test("a link is modelled with its href and inline children, and the href is not annotation-parsed here", () => {
const result = parse("[the docs](https://example.com/@stuff)\n");
const para = result.blocks[0];
if (para?.type !== "paragraph") throw new Error("expected a paragraph block");
expect(para.children).toEqual([
{
type: "link",
href: "https://example.com/@stuff",
children: [{ type: "text", value: "the docs" }],
},
]);
});
test("a code span preserves its raw content verbatim", () => {
const result = parse("Use `@sugar{1 tbsp}` literally.\n");
const para = result.blocks[0];
if (para?.type !== "paragraph") throw new Error("expected a paragraph block");
expect(para.children).toContainEqual({ type: "codeSpan", value: "@sugar{1 tbsp}" });
});
test("an unmodelled block (a GFM table) falls through to a RawBlock with byte-for-byte source", () => {
const table = "| a | b |\n| - | - |\n| 1 | 2 |";
const result = parse(`${table}\n`);
expect(result.blocks).toEqual([{ type: "raw", value: table }]);
});
test("an unmodelled inline (GFM strikethrough) falls through to a RawInline with byte-for-byte source", () => {
const result = parse("done ~~scratch~~ now\n");
const para = result.blocks[0];
if (para?.type !== "paragraph") throw new Error("expected a paragraph block");
expect(para.children).toEqual([
{ type: "text", value: "done " },
{ type: "rawInline", value: "~~scratch~~" },
{ type: "text", value: " now" },
]);
});
test("frontmatter is not emitted as a block", () => {
const input = `---
title: X
---
Body.
`;
const result = parse(input);
expect(result.blocks).toEqual([
{ type: "paragraph", children: [{ type: "text", value: "Body." }] },
]);
});
});

103
packages/core/src/types.ts Normal file
View File

@@ -0,0 +1,103 @@
// The public AST node and document types returned by parse.
export type Frontmatter = Record<string, unknown>;
export interface Diagnostic {
severity: "warning";
code: string;
message: string;
}
export interface TextNode {
type: "text";
value: string;
}
export interface EmphasisNode {
type: "emphasis";
children: InlineNode[];
}
export interface StrongNode {
type: "strong";
children: InlineNode[];
}
export interface CodeSpanNode {
type: "codeSpan";
value: string;
}
export interface LinkNode {
type: "link";
href: string;
children: InlineNode[];
}
// Verbatim source for any inline construct core does not model, sliced from the
// original input so the unmodelled span round-trips byte-for-byte.
export interface RawInline {
type: "rawInline";
value: string;
}
export type InlineNode = TextNode | EmphasisNode | StrongNode | CodeSpanNode | LinkNode | RawInline;
export interface HeadingBlock {
type: "heading";
level: 1 | 2 | 3 | 4 | 5 | 6;
children: InlineNode[];
}
export interface ParagraphBlock {
type: "paragraph";
children: InlineNode[];
}
export interface ListItemBlock {
type: "listItem";
children: Block[];
}
export interface ListBlock {
type: "list";
ordered: boolean;
items: ListItemBlock[];
}
export interface BlockquoteBlock {
type: "blockquote";
children: Block[];
}
export interface CodeBlock {
type: "code";
lang?: string;
value: string;
}
export interface ThematicBreakBlock {
type: "thematicBreak";
}
// Verbatim source for any block construct core does not model, sliced from the
// original input so the unmodelled block round-trips byte-for-byte.
export interface RawBlock {
type: "raw";
value: string;
}
export type Block =
| HeadingBlock
| ParagraphBlock
| ListBlock
| BlockquoteBlock
| CodeBlock
| ThematicBreakBlock
| RawBlock;
export interface DocumentAST {
frontmatter: Frontmatter;
blocks: Block[];
diagnostics: Diagnostic[];
}

278
scripts/test-report.ts Normal file
View File

@@ -0,0 +1,278 @@
// Runs the test suite and renders reports/test-report.md: a summary, a
// describe-grouped inventory, and a coverage table.
// Any positional arguments are forwarded to `bun test` as path filters; with
// none, the whole repo runs.
// The report is written even when tests fail, and the process exits with the
// test run's own status so CI can gate on it.
// Split into run -> parse -> render so a future CI job can reuse parse+render
// on artifacts it already produced.
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { XMLParser } from "fast-xml-parser";
const REPORTS_DIR = "reports";
const JUNIT_PATH = `${REPORTS_DIR}/junit.xml`;
const LCOV_PATH = "coverage/lcov.info";
const REPORT_PATH = `${REPORTS_DIR}/test-report.md`;
type TestStatus = "passed" | "failed" | "todo";
interface TestCase {
name: string;
status: TestStatus;
time: number;
message?: string;
}
interface Suite {
name: string;
suites: Suite[];
tests: TestCase[];
}
interface Junit {
totals: { tests: number; failures: number; skipped: number; time: number };
files: Suite[];
}
interface FileCoverage {
file: string;
linesFound: number;
linesHit: number;
funcsFound: number;
funcsHit: number;
uncovered: number[];
}
// --- run ---
function runTests(paths: string[]): number {
mkdirSync(REPORTS_DIR, { recursive: true });
const proc = Bun.spawnSync(
[
"bun",
"test",
...paths,
"--coverage",
"--coverage-reporter=lcov",
"--reporter=junit",
`--reporter-outfile=${JUNIT_PATH}`,
],
{ stdout: "inherit", stderr: "inherit" },
);
return proc.exitCode ?? 1;
}
// --- parse ---
const xml = new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: "@_",
isArray: (name) => name === "testsuite" || name === "testcase",
});
function parseJunit(path: string): Junit {
const root = xml.parse(readFileSync(path, "utf8")).testsuites;
return {
totals: {
tests: Number(root["@_tests"] ?? 0),
failures: Number(root["@_failures"] ?? 0),
skipped: Number(root["@_skipped"] ?? 0),
time: Number(root["@_time"] ?? 0),
},
files: (root.testsuite ?? []).map(parseSuite),
};
}
// biome-ignore lint/suspicious/noExplicitAny: raw fast-xml-parser nodes are untyped
function parseSuite(node: any): Suite {
return {
name: node["@_name"] ?? "(unnamed)",
suites: (node.testsuite ?? []).map(parseSuite),
tests: (node.testcase ?? []).map(parseCase),
};
}
// biome-ignore lint/suspicious/noExplicitAny: raw fast-xml-parser nodes are untyped
function parseCase(node: any): TestCase {
const name = node["@_name"] ?? "(unnamed)";
const time = Number(node["@_time"] ?? 0);
if (node.failure !== undefined) {
const message = node.failure["@_message"] ?? node.failure["#text"] ?? "";
return { name, status: "failed", time, message: String(message) };
}
if (node.skipped !== undefined) {
return { name, status: "todo", time };
}
return { name, status: "passed", time };
}
function parseLcov(path: string): FileCoverage[] {
if (!existsSync(path)) {
return [];
}
const records: FileCoverage[] = [];
let current: FileCoverage | null = null;
for (const line of readFileSync(path, "utf8").split("\n")) {
if (line.startsWith("SF:")) {
current = {
file: line.slice(3).trim(),
linesFound: 0,
linesHit: 0,
funcsFound: 0,
funcsHit: 0,
uncovered: [],
};
} else if (!current) {
// Skip anything before the first source record.
} else if (line.startsWith("FNF:")) {
current.funcsFound = Number(line.slice(4));
} else if (line.startsWith("FNH:")) {
current.funcsHit = Number(line.slice(4));
} else if (line.startsWith("DA:")) {
const [lineNo, hits] = line.slice(3).split(",");
current.linesFound++;
if (Number(hits) > 0) {
current.linesHit++;
} else {
current.uncovered.push(Number(lineNo));
}
} else if (line.startsWith("end_of_record")) {
records.push(current);
current = null;
}
}
return records;
}
// --- render ---
const ICON: Record<TestStatus, string> = {
passed: "✅",
failed: "❌",
todo: "⏭️",
};
function pct(hit: number, found: number): string {
if (found === 0) {
return "—";
}
return `${((hit / found) * 100).toFixed(1)}% (${hit}/${found})`;
}
// Collapse consecutive line numbers into ranges, e.g. 33,34,...,40 -> "33-40".
function ranges(lines: number[]): string {
if (lines.length === 0) {
return "—";
}
const sorted = [...lines].sort((a, b) => a - b);
const out: string[] = [];
let start = sorted[0];
let prev = sorted[0];
for (const n of sorted.slice(1)) {
if (n === prev + 1) {
prev = n;
continue;
}
out.push(start === prev ? `${start}` : `${start}-${prev}`);
start = n;
prev = n;
}
out.push(start === prev ? `${start}` : `${start}-${prev}`);
return out.join(", ");
}
function renderSuite(suite: Suite, depth: number, lines: string[]): void {
const indent = " ".repeat(depth);
for (const child of suite.suites) {
lines.push(`${indent}- **${child.name}**`);
renderSuite(child, depth + 1, lines);
}
for (const test of suite.tests) {
const timing =
test.status === "passed" && test.time > 0 ? ` _(${(test.time * 1000).toFixed(0)}ms)_` : "";
lines.push(`${indent}- ${ICON[test.status]} ${test.name}${timing}`);
if (test.status === "failed" && test.message) {
lines.push(`${indent} - \`${test.message.replace(/`/g, "'").replace(/\n/g, " ")}\``);
}
}
}
function renderReport(junit: Junit, coverage: FileCoverage[], meta: string): string {
const { tests, failures, skipped, time } = junit.totals;
const passed = tests - failures - skipped;
const result = failures === 0 ? "✅ All passing" : `${failures} failing`;
const lines: string[] = [];
lines.push("# Test Report", "", `_${meta}_`, "");
lines.push("## Summary", "");
lines.push("| Metric | Value |", "| --- | --- |");
lines.push(`| Result | ${result} |`);
lines.push(`| Passed | ${passed} |`);
lines.push(`| Failed | ${failures} |`);
lines.push(`| Todo | ${skipped} |`);
lines.push(`| Total | ${tests} |`);
lines.push(`| Duration | ${time.toFixed(2)}s |`, "");
lines.push("## Test inventory", "");
for (const file of junit.files) {
lines.push(`### \`${file.name}\``, "");
renderSuite(file, 0, lines);
lines.push("");
}
lines.push("## Coverage", "");
if (coverage.length === 0) {
lines.push("_No coverage data found._", "");
} else {
lines.push("| File | Lines | Functions | Uncovered |", "| --- | --- | --- | --- |");
const total = { linesFound: 0, linesHit: 0, funcsFound: 0, funcsHit: 0 };
for (const file of coverage) {
total.linesFound += file.linesFound;
total.linesHit += file.linesHit;
total.funcsFound += file.funcsFound;
total.funcsHit += file.funcsHit;
lines.push(
`| \`${file.file}\` | ${pct(file.linesHit, file.linesFound)} | ${pct(file.funcsHit, file.funcsFound)} | ${ranges(file.uncovered)} |`,
);
}
lines.push(
`| **Total** | **${pct(total.linesHit, total.linesFound)}** | **${pct(total.funcsHit, total.funcsFound)}** | |`,
"",
);
lines.push(
"> Coverage instruments in-process code only; code exercised solely through a subprocess (such as the CLI entry point) is not counted.",
"",
);
}
return lines.join("\n");
}
// --- main ---
function gitSha(): string {
const proc = Bun.spawnSync(["git", "rev-parse", "--short", "HEAD"]);
return proc.exitCode === 0 ? proc.stdout.toString().trim() : "unknown";
}
const paths = process.argv.slice(2);
const exitCode = runTests(paths);
mkdirSync(REPORTS_DIR, { recursive: true });
const scope = paths.length > 0 ? paths.join(", ") : "whole repo";
const meta = `Generated ${new Date().toISOString()} · commit ${gitSha()} · ${scope}`;
if (!existsSync(JUNIT_PATH)) {
writeFileSync(
REPORT_PATH,
`# Test Report\n\n_${meta}_\n\nThe test run produced no JUnit output; it likely failed to start.\n`,
);
console.error(`Test run produced no JUnit output. Wrote a stub to ${REPORT_PATH}.`);
process.exit(exitCode || 1);
}
writeFileSync(REPORT_PATH, renderReport(parseJunit(JUNIT_PATH), parseLcov(LCOV_PATH), meta));
console.log(`Wrote ${REPORT_PATH}`);
process.exit(exitCode);