From ab309d322661f1ea6447267105ad63a9ed0b03f2 Mon Sep 17 00:00:00 2001 From: alexion Date: Tue, 28 Jul 2026 23:31:03 -0400 Subject: [PATCH] feat: add kitchen view command and parser skeleton (task 0003) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cut the first vertical slice through both layers: a runnable `kitchen view ` 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. --- .../0008-errors-as-values-at-cli-boundary.md | 31 ++ .../adr/0009-testing-tiers-and-boundaries.md | 44 +++ .claude/tasks/0003-view-skeleton.md | 77 +++++ .gitignore | 6 + bun.lock | 147 +++++++++ bun.nix | 272 +++++++++++++++++ bunfig.toml | 6 + fixtures/prose.md | 22 ++ flake.nix | 5 - package.json | 5 +- packages/bin/package.json | 6 +- packages/bin/src/end_to_end_test.ts | 65 ++++ packages/bin/src/index.ts | 55 +++- packages/bin/src/index_test.ts | 88 +++++- packages/bin/src/integration_test.ts | 5 - packages/bin/src/render.ts | 46 +++ packages/bin/src/render_test.ts | 99 +++++++ packages/bin/src/smoke_test.ts | 6 - packages/core/package.json | 6 + packages/core/src/index.ts | 3 +- packages/core/src/index_test.ts | 86 ------ packages/core/src/integration_test.ts | 8 - packages/core/src/parse.ts | 46 +++ packages/core/src/parse_test.ts | 102 +++++++ packages/core/src/types.ts | 35 +++ scripts/test-report.ts | 278 ++++++++++++++++++ 26 files changed, 1431 insertions(+), 118 deletions(-) create mode 100644 .claude/adr/0008-errors-as-values-at-cli-boundary.md create mode 100644 .claude/adr/0009-testing-tiers-and-boundaries.md create mode 100644 .claude/tasks/0003-view-skeleton.md create mode 100644 bunfig.toml create mode 100644 fixtures/prose.md create mode 100644 packages/bin/src/end_to_end_test.ts delete mode 100644 packages/bin/src/integration_test.ts create mode 100644 packages/bin/src/render.ts create mode 100644 packages/bin/src/render_test.ts delete mode 100644 packages/bin/src/smoke_test.ts delete mode 100644 packages/core/src/index_test.ts delete mode 100644 packages/core/src/integration_test.ts create mode 100644 packages/core/src/parse.ts create mode 100644 packages/core/src/parse_test.ts create mode 100644 packages/core/src/types.ts create mode 100644 scripts/test-report.ts diff --git a/.claude/adr/0008-errors-as-values-at-cli-boundary.md b/.claude/adr/0008-errors-as-values-at-cli-boundary.md new file mode 100644 index 0000000..7512c46 --- /dev/null +++ b/.claude/adr/0008-errors-as-values-at-cli-boundary.md @@ -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`, 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. diff --git a/.claude/adr/0009-testing-tiers-and-boundaries.md b/.claude/adr/0009-testing-tiers-and-boundaries.md new file mode 100644 index 0000000..800de30 --- /dev/null +++ b/.claude/adr/0009-testing-tiers-and-boundaries.md @@ -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. diff --git a/.claude/tasks/0003-view-skeleton.md b/.claude/tasks/0003-view-skeleton.md new file mode 100644 index 0000000..624eb9e --- /dev/null +++ b/.claude/tasks/0003-view-skeleton.md @@ -0,0 +1,77 @@ +--- +spec: cli-view +--- + +## What to build + +The walking skeleton: a runnable `kitchen view ` 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 ` 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 1–6) 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 ` 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 1–6, 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 1–3, 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. diff --git a/.gitignore b/.gitignore index 69ae774..31ad133 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/bun.lock b/bun.lock index d2af586..c80262e 100644 --- a/bun.lock +++ b/bun.lock @@ -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,21 @@ }, "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-parse": "^11.0.0", + "unified": "^11.0.5", + "yaml": "^2.9.0", + }, }, }, "packages": { @@ -48,16 +59,152 @@ "@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=="], + "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=="], + + "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-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-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-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=="], + "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=="], } } diff --git a/bun.nix b/bun.nix index 43a395f..7cb5de8 100644 --- a/bun.nix +++ b/bun.nix @@ -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,256 @@ url = "https://registry.npmjs.org/bun2nix/-/bun2nix-2.1.2.tgz"; hash = "sha512-0wx6Ar5ccrz4aSD5prbShwymjDEXFh7Bucxs+YrpAMa67TnVB95Hv8FV3oaQEbtOx6QGgIAyOmap6Y3WCRqetg=="; }; + "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=="; + }; + "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-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-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-parse@11.0.0" = fetchurl { + url = "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz"; + hash = "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA=="; + }; "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=="; + }; } diff --git a/bunfig.toml b/bunfig.toml new file mode 100644 index 0000000..dc9a260 --- /dev/null +++ b/bunfig.toml @@ -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 diff --git a/fixtures/prose.md b/fixtures/prose.md new file mode 100644 index 0000000..671f1b6 --- /dev/null +++ b/fixtures/prose.md @@ -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. diff --git a/flake.nix b/flake.nix index a472f58..b88564f 100644 --- a/flake.nix +++ b/flake.nix @@ -93,11 +93,6 @@ doCheck = true; installPhase = "touch $out"; }; - - smoke = pkgs.runCommand "kitchen-smoke" { } '' - ${kitchen}/bin/kitchen --help - touch $out - ''; }; }; }; diff --git a/package.json b/package.json index af0041e..aa444ed 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/packages/bin/package.json b/packages/bin/package.json index 1887cdb..9423872 100644 --- a/packages/bin/package.json +++ b/packages/bin/package.json @@ -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" } } diff --git a/packages/bin/src/end_to_end_test.ts b/packages/bin/src/end_to_end_test.ts new file mode 100644 index 0000000..a3709d7 --- /dev/null +++ b/packages/bin/src/end_to_end_test.ts @@ -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"); + }); +}); diff --git a/packages/bin/src/index.ts b/packages/bin/src/index.ts index 5ef81e3..21a66b7 100644 --- a/packages/bin/src/index.ts +++ b/packages/bin/src/index.ts @@ -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 { + 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 { + 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("", "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(); +} diff --git a/packages/bin/src/index_test.ts b/packages/bin/src/index_test.ts index 8bacff0..e2e2708 100644 --- a/packages/bin/src/index_test.ts +++ b/packages/bin/src/index_test.ts @@ -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"); + }); }); diff --git a/packages/bin/src/integration_test.ts b/packages/bin/src/integration_test.ts deleted file mode 100644 index 160fe43..0000000 --- a/packages/bin/src/integration_test.ts +++ /dev/null @@ -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"); -}); diff --git a/packages/bin/src/render.ts b/packages/bin/src/render.ts new file mode 100644 index 0000000..941c7f4 --- /dev/null +++ b/packages/bin/src/render.ts @@ -0,0 +1,46 @@ +import type { Block, DocumentAST, Frontmatter, HeadingBlock, InlineNode } from "@kitchen-md/core"; +import chalk from "chalk"; +import { stringify as stringifyYaml } from "yaml"; + +const SEPARATOR = "─".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(SEPARATOR)}\n`; +} + +function renderBlock(block: Block): string { + if (block.type === "heading") { + return `${styleHeading(block.level)(renderInline(block.children))}\n`; + } + return `${renderInline(block.children)}\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 renderInline(nodes: InlineNode[]): string { + return nodes.map((node) => node.value).join(""); +} diff --git a/packages/bin/src/render_test.ts b/packages/bin/src/render_test.ts new file mode 100644 index 0000000..cf1823c --- /dev/null +++ b/packages/bin/src/render_test.ts @@ -0,0 +1,99 @@ +import { describe, expect, test } from "bun:test"; +import type { DocumentAST, HeadingBlock } from "@kitchen-md/core"; +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 1–6", () => { + 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`); + } + }); +}); diff --git a/packages/bin/src/smoke_test.ts b/packages/bin/src/smoke_test.ts deleted file mode 100644 index 03dbde7..0000000 --- a/packages/bin/src/smoke_test.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { describe, test } from "bun:test"; - -describe("smoke", () => { - test.todo("kitchen --help exits with code 0"); - test.todo("kitchen parse outputs structured JSON covering all annotation types"); -}); diff --git a/packages/core/package.json b/packages/core/package.json index 3107281..43c1f38 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -8,5 +8,11 @@ }, "scripts": { "test": "bun test" + }, + "dependencies": { + "remark-frontmatter": "^5.0.0", + "remark-parse": "^11.0.0", + "unified": "^11.0.5", + "yaml": "^2.9.0" } } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index cb0ff5c..3ab6ba4 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1 +1,2 @@ -export {}; +export { parse } from "./parse.ts"; +export type * from "./types.ts"; diff --git a/packages/core/src/index_test.ts b/packages/core/src/index_test.ts deleted file mode 100644 index ddd8f6b..0000000 --- a/packages/core/src/index_test.ts +++ /dev/null @@ -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"); -}); diff --git a/packages/core/src/integration_test.ts b/packages/core/src/integration_test.ts deleted file mode 100644 index 3b5e9bb..0000000 --- a/packages/core/src/integration_test.ts +++ /dev/null @@ -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)"); -}); diff --git a/packages/core/src/parse.ts b/packages/core/src/parse.ts new file mode 100644 index 0000000..e5bc52a --- /dev/null +++ b/packages/core/src/parse.ts @@ -0,0 +1,46 @@ +import type { PhrasingContent, Root, RootContent } from "mdast"; +import remarkFrontmatter from "remark-frontmatter"; +import remarkParse from "remark-parse"; +import { unified } from "unified"; +import { parse as parseYaml } from "yaml"; +import type { Block, DocumentAST, Frontmatter, InlineNode } from "./types.ts"; + +const processor = unified().use(remarkParse).use(remarkFrontmatter); + +export function parse(input: string): DocumentAST { + const tree = processor.parse(input); + const frontmatter = extractFrontmatter(tree); + const blocks = tree.children.flatMap(translateBlock); + 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): Block[] { + if (node.type === "heading") { + return [{ type: "heading", level: node.depth, children: translateInline(node.children) }]; + } + if (node.type === "paragraph") { + return [{ type: "paragraph", children: translateInline(node.children) }]; + } + return []; +} + +function translateInline(nodes: PhrasingContent[]): InlineNode[] { + return nodes.flatMap((node) => { + if (node.type === "text") { + return [{ type: "text", value: node.value }]; + } + return []; + }); +} diff --git a/packages/core/src/parse_test.ts b/packages/core/src/parse_test.ts new file mode 100644 index 0000000..767c196 --- /dev/null +++ b/packages/core/src/parse_test.ts @@ -0,0 +1,102 @@ +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 1–6 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([]); + }); +}); diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts new file mode 100644 index 0000000..ab6dee8 --- /dev/null +++ b/packages/core/src/types.ts @@ -0,0 +1,35 @@ +// The public AST node and document types returned by parse. + +export type Frontmatter = Record; + +export interface Diagnostic { + severity: "warning"; + code: string; + message: string; +} + +export interface TextNode { + type: "text"; + value: string; +} + +export type InlineNode = TextNode; + +export interface HeadingBlock { + type: "heading"; + level: 1 | 2 | 3 | 4 | 5 | 6; + children: InlineNode[]; +} + +export interface ParagraphBlock { + type: "paragraph"; + children: InlineNode[]; +} + +export type Block = HeadingBlock | ParagraphBlock; + +export interface DocumentAST { + frontmatter: Frontmatter; + blocks: Block[]; + diagnostics: Diagnostic[]; +} diff --git a/scripts/test-report.ts b/scripts/test-report.ts new file mode 100644 index 0000000..e90e7a6 --- /dev/null +++ b/scripts/test-report.ts @@ -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 = { + 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); -- 2.47.3