diff --git a/.claude/tasks/0004-richer-blocks-and-inline.md b/.claude/tasks/0004-richer-blocks-and-inline.md new file mode 100644 index 0000000..e2e210e --- /dev/null +++ b/.claude/tasks/0004-richer-blocks-and-inline.md @@ -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. diff --git a/bun.lock b/bun.lock index c80262e..829454c 100644 --- a/bun.lock +++ b/bun.lock @@ -30,6 +30,7 @@ "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", @@ -81,6 +82,8 @@ "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=="], @@ -113,10 +116,26 @@ "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=="], @@ -129,6 +148,20 @@ "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=="], @@ -177,8 +210,12 @@ "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=="], diff --git a/bun.nix b/bun.nix index 7cb5de8..bee5af6 100644 --- a/bun.nix +++ b/bun.nix @@ -95,6 +95,10 @@ 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=="; @@ -159,6 +163,14 @@ 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=="; @@ -167,6 +179,30 @@ 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=="; @@ -187,6 +223,34 @@ 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=="; @@ -287,10 +351,18 @@ 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=="; diff --git a/packages/bin/src/render.ts b/packages/bin/src/render.ts index 941c7f4..b3be206 100644 --- a/packages/bin/src/render.ts +++ b/packages/bin/src/render.ts @@ -1,8 +1,17 @@ -import type { Block, DocumentAST, Frontmatter, HeadingBlock, InlineNode } from "@kitchen-md/core"; +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 SEPARATOR = "─".repeat(40); +const RULE = "─".repeat(40); export function render(ast: DocumentAST): string { const body = ast.blocks.map(renderBlock).join(""); @@ -13,14 +22,26 @@ function renderFrontmatter(frontmatter: Frontmatter): string { if (Object.keys(frontmatter).length === 0) { return ""; } - return `${stringifyYaml(frontmatter)}${chalk.dim(SEPARATOR)}\n`; + return `${stringifyYaml(frontmatter)}${chalk.dim(RULE)}\n`; } function renderBlock(block: Block): string { - if (block.type === "heading") { - return `${styleHeading(block.level)(renderInline(block.children))}\n`; + 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`; } - return `${renderInline(block.children)}\n\n`; } // Each level gets a distinct style, tapering from bold at level 1 toward dim at level 6. @@ -41,6 +62,46 @@ function styleHeading(level: HeadingBlock["level"]): (text: string) => string { } } -function renderInline(nodes: InlineNode[]): string { - return nodes.map((node) => node.value).join(""); +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; + } } diff --git a/packages/bin/src/render_test.ts b/packages/bin/src/render_test.ts index cf1823c..5e86b1f 100644 --- a/packages/bin/src/render_test.ts +++ b/packages/bin/src/render_test.ts @@ -1,5 +1,6 @@ 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", () => { @@ -96,4 +97,135 @@ describe("render", () => { 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"); + }); }); diff --git a/packages/core/package.json b/packages/core/package.json index 43c1f38..4c4e151 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -11,6 +11,7 @@ }, "dependencies": { "remark-frontmatter": "^5.0.0", + "remark-gfm": "^4.0.1", "remark-parse": "^11.0.0", "unified": "^11.0.5", "yaml": "^2.9.0" diff --git a/packages/core/src/parse.ts b/packages/core/src/parse.ts index e5bc52a..330d8cd 100644 --- a/packages/core/src/parse.ts +++ b/packages/core/src/parse.ts @@ -1,16 +1,17 @@ -import type { PhrasingContent, Root, RootContent } from "mdast"; +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 } from "./types.ts"; +import type { Block, DocumentAST, Frontmatter, InlineNode, ListItemBlock } from "./types.ts"; -const processor = unified().use(remarkParse).use(remarkFrontmatter); +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(translateBlock); + const blocks = tree.children.flatMap((node) => translateBlock(node, input)); return { frontmatter, blocks, diagnostics: [] }; } @@ -26,21 +27,74 @@ function extractFrontmatter(tree: Root): Frontmatter { return {}; } -function translateBlock(node: RootContent): Block[] { - if (node.type === "heading") { - return [{ type: "heading", level: node.depth, children: translateInline(node.children) }]; +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) }]; } - 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 }]; +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) }]; } - return []; }); } + +// 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); +} diff --git a/packages/core/src/parse_test.ts b/packages/core/src/parse_test.ts index 767c196..ac5b916 100644 --- a/packages/core/src/parse_test.ts +++ b/packages/core/src/parse_test.ts @@ -99,4 +99,165 @@ A paragraph under it. 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." }] }, + ]); + }); }); diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index ab6dee8..12549a9 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -13,7 +13,35 @@ export interface TextNode { value: string; } -export type InlineNode = TextNode; +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"; @@ -26,7 +54,47 @@ export interface ParagraphBlock { children: InlineNode[]; } -export type Block = HeadingBlock | ParagraphBlock; +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;