1 Commits

Author SHA1 Message Date
b0fdc22165 feat: parse and render Obsidian cross-references (task 0005)
Model `[[wikilinks]]` and `![[transclusions]]` as distinct inline node
types sharing a `{ target, anchor?, display? }` shape, and render them:
wikilinks underlined (display text or target), transclusions as their
raw source text.

remark-wiki-link is added to the pipeline for `[[…]]`. It does not
recognise `![[…]]` embeds or split the `#anchor` from the target, so
transclusions are recovered by scanning text runs and anchors are split
in the translation layer. Its alias divider is set to `|` so Step
Reference anchors like `#rolling:2` survive.
2026-07-29 20:05:35 -04:00
9 changed files with 441 additions and 5 deletions

View File

@@ -0,0 +1,63 @@
---
spec: core-parser
blocked-by: 0004-richer-blocks-and-inline
---
## What to build
Add Obsidian cross-references so `[[links]]` and `![[embeds]]` — including KitchenMD Step References — are typed nodes the parser surfaces and the renderer styles.
In `@kitchen-md/core`, model `WikilinkNode` and `TransclusionNode`, both `{ target, anchor?, display? }`: `target` is the filename without extension, `anchor` is the part after `#` passed through verbatim, `display` is the alias after `|`.
The node type — not a boolean flag — discriminates a reference (`[[…]]`) from an embed (`![[…]]`).
`TransclusionNode` covers KitchenMD Step References (`![[file#section:N]]`, `![[file#N]]`), whose anchor is passed through as-is; Step Reference resolution is out of scope.
Add remark-wiki-link to the pipeline.
In `@kitchen-md/bin`, render wikilinks distinctly from surrounding prose (underline or distinct colour), using display text or the target when there is no display text.
Render transclusions as their raw source text (e.g. `![[file#section:1]]`).
## Acceptance criteria
- [x] `WikilinkNode` and `TransclusionNode` share the `{ target, anchor?, display? }` shape and are distinct node types
- [x] Wikilinks parse with a bare target, with an anchor, and with a display alias; the anchor is passed through verbatim
- [x] Transclusions parse with a display alias and with a Step Reference anchor (`#section:N` and `#N`) passed through as-is
- [x] `render` shows wikilinks distinctly (display text, or target when absent)
- [x] `render` shows transclusions as their raw source text
- [x] Core unit tests cover wikilink (bare, anchor, display) and transclusion (bare, display, Step Reference anchor)
- [x] Renderer unit tests (ANSI stripped) cover wikilink and transclusion rendering
## Implementation Notes
All acceptance criteria are met, with no dropped or deferred criteria.
The following decisions are worth recording.
### remark-wiki-link handles only `[[…]]`, so transclusions are recovered separately
The installed `remark-wiki-link@2.0.1` (landakram) parses `[[…]]` into `wikiLink` mdast nodes but does not recognise `![[…]]` embeds.
The leading `!` makes remark attempt an image, which fails and leaves the whole span as literal text.
Transclusions are therefore recovered in the translation layer by scanning each text run for `!\[\[…]]` and splitting it into text and `TransclusionNode` parts.
This keeps the plugin in the pipeline as the spec asks while still surfacing transclusions as typed nodes.
### Anchors are split in the translation layer, not by the plugin
remark-wiki-link leaves the `#anchor` attached to the node's `value` and does not separate it.
A shared `splitAnchor` helper splits `target#anchor` at the first `#` for both node types, so the anchor is passed through verbatim and the key is omitted entirely when absent.
### The alias divider is set to `|`
The plugin defaults its alias divider to `:`, which would consume a Step Reference anchor such as `#rolling:2`.
It is configured with `aliasDivider: "|"` so that only a real Obsidian alias is split off, and a display alias is recorded only when it differs from the node's value.
### Discriminants mirror the interface names
Following task 0004's precedent for `codeSpan`, the discriminants are `"wikilink"` and `"transclusion"` — the interface names lowercased — since neither construct has an mdast counterpart to borrow a type name from.
### Optional keys are omitted when absent
Consistent with task 0004, `anchor` and `display` are spread in only when present rather than set to `undefined`, so parser output deep-equals the expected node shape without stray keys.
### Rendering follows task 0003/0004 conventions
Wikilinks render underlined, showing the display text or the target when there is no display, matching how links render (both are references).
When a wikilink has an anchor but no display, only the target text shows — this follows the spec's wording ("display text, or the target when there is no display text") exactly.
Transclusions render as their reconstructed raw source (`![[target#anchor|display]]`), unstyled, since resolving the embed is out of scope.
The renderer tests assert ANSI-stripped text plus the presence of styling, never particular colours.

View File

@@ -32,12 +32,15 @@
"remark-frontmatter": "^5.0.0",
"remark-gfm": "^4.0.1",
"remark-parse": "^11.0.0",
"remark-wiki-link": "^2.0.1",
"unified": "^11.0.5",
"yaml": "^2.9.0",
},
},
},
"packages": {
"@babel/runtime": ["@babel/runtime@7.29.7", "", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="],
"@biomejs/biome": ["@biomejs/biome@2.5.3", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.5.3", "@biomejs/cli-darwin-x64": "2.5.3", "@biomejs/cli-linux-arm64": "2.5.3", "@biomejs/cli-linux-arm64-musl": "2.5.3", "@biomejs/cli-linux-x64": "2.5.3", "@biomejs/cli-linux-x64-musl": "2.5.3", "@biomejs/cli-win32-arm64": "2.5.3", "@biomejs/cli-win32-x64": "2.5.3" }, "bin": { "biome": "bin/biome" } }, "sha512-MrJswFdei9EfDwwUy2tQrPDpK0AO+RmMFvBoaaJ6ayBc3sUbHdCE+XG5N8vp+5So41ZupZJQm0roHFFhMGVD7A=="],
"@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.5.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-QhYP9muVQ0nUO5zztFuPbEwi4+94sJWVjaZds9aMi1l/KNZBiUjdiSUrGHsTaMGDXrYl+r4AS2sUKfgH3w+V3g=="],
@@ -88,6 +91,10 @@
"character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="],
"character-entities-legacy": ["character-entities-legacy@1.1.4", "", {}, "sha512-3Xnr+7ZFS1uxeiUDvV02wQ+QDbc55o97tIV5zHScSPJpcLm/r0DFPcoY3tYRp+VZukxuMeKgXYmsXQHO05zQeA=="],
"character-reference-invalid": ["character-reference-invalid@1.1.4", "", {}, "sha512-mKKUkUbhPpQlCOfIuZkvSEgktjPFIsZKRRbC6KWVEMvlzblj3i3asQv5ODsrwt0N3pHAEvjP8KTQPHkp0+6jOg=="],
"commander": ["commander@15.0.0", "", {}, "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
@@ -110,6 +117,14 @@
"format": ["format@0.2.2", "", {}, "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww=="],
"is-alphabetical": ["is-alphabetical@1.0.4", "", {}, "sha512-DwzsA04LQ10FHTZuL0/grVDk4rFoVH1pjAToYwBrHSxcrBIGQuXrQMtD5U1b0U2XVgKZCTLLP8u2Qxqhy3l2Vg=="],
"is-alphanumerical": ["is-alphanumerical@1.0.4", "", { "dependencies": { "is-alphabetical": "^1.0.0", "is-decimal": "^1.0.0" } }, "sha512-UzoZUr+XfVz3t3v4KyGEniVL9BDRoQtY7tOyrRybkVNjDFWyo1yhXNGrrBTQxp3ib9BLAWs7k2YKBQsFRkZG9A=="],
"is-decimal": ["is-decimal@1.0.4", "", {}, "sha512-RGdriMmQQvZ2aqaQq3awNA6dCGtKpiDFcOzrTWrDAT2MiWrKQVPmxLGHl7Y2nNu6led0kEyoX0enY0qXYsv9zw=="],
"is-hexadecimal": ["is-hexadecimal@1.0.4", "", {}, "sha512-gyPJuv83bHMpocVYoqof5VDiZveEoGoFL8m3BXNb2VW8Xs+rz9kqO8LOQ5DH6EsuvilT1ApazU0pyl+ytbPtlw=="],
"is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="],
"is-unsafe": ["is-unsafe@2.0.0", "", {}, "sha512-2LdV822R+wmI86unXA93WCFpL6g+av8ynWk0nrHyJqGop5VoocYsSLFgN8jrfalT6iGeLNM4KXuVSsULP53kEA=="],
@@ -142,6 +157,8 @@
"mdast-util-to-string": ["mdast-util-to-string@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="],
"mdast-util-wiki-link": ["mdast-util-wiki-link@0.1.2", "", { "dependencies": { "@babel/runtime": "^7.12.1", "mdast-util-to-markdown": "^0.6.5" } }, "sha512-DTcDyOxKDo3pB3fc0zQlD8myfQjYkW4hazUKI9PUyhtoj9JBeHC2eIdlVXmaT22bZkFAVU2d47B6y2jVKGoUQg=="],
"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=="],
@@ -162,6 +179,8 @@
"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-extension-wiki-link": ["micromark-extension-wiki-link@0.0.4", "", { "dependencies": { "@babel/runtime": "^7.12.1" } }, "sha512-dJc8AfnoU8BHkN+7fWZvIS20SMsMS1ZlxQUn6We67MqeKbOiEDZV5eEvCpwqGBijbJbxX3Kxz879L4K9HIiOvw=="],
"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=="],
@@ -206,6 +225,8 @@
"neverthrow": ["neverthrow@8.2.0", "", { "optionalDependencies": { "@rollup/rollup-linux-x64-gnu": "^4.24.0" } }, "sha512-kOCT/1MCPAxY5iUV3wytNFUMUolzuwd/VF/1KCx7kf6CutrOsTie+84zTGTpgQycjvfLdBBdvBvFLqFD2c0wkQ=="],
"parse-entities": ["parse-entities@2.0.0", "", { "dependencies": { "character-entities": "^1.0.0", "character-entities-legacy": "^1.0.0", "character-reference-invalid": "^1.0.0", "is-alphanumerical": "^1.0.0", "is-decimal": "^1.0.0", "is-hexadecimal": "^1.0.0" } }, "sha512-kkywGpCcRYhqQIchaWqZ875wzpS/bMKhz5HnN3p7wveJTkTtyAB/AlnS0f8DFSqYW1T82t6yEAkEcB+A1I3MbQ=="],
"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=="],
@@ -216,6 +237,10 @@
"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=="],
"remark-wiki-link": ["remark-wiki-link@2.0.1", "", { "dependencies": { "@babel/runtime": "^7.4.4", "mdast-util-wiki-link": "^0.1.2", "micromark-extension-wiki-link": "^0.0.4" } }, "sha512-F8Eut1E7GWfFm4ZDTI6/4ejeZEHZgnVk6E933Yqd/ssYsc4AyI32aGakxwsGcEzbbE7dkWi1EfLlGAdGgOZOsA=="],
"repeat-string": ["repeat-string@1.6.1", "", {}, "sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w=="],
"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=="],
@@ -243,5 +268,17 @@
"yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="],
"zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
"mdast-util-wiki-link/mdast-util-to-markdown": ["mdast-util-to-markdown@0.6.5", "", { "dependencies": { "@types/unist": "^2.0.0", "longest-streak": "^2.0.0", "mdast-util-to-string": "^2.0.0", "parse-entities": "^2.0.0", "repeat-string": "^1.0.0", "zwitch": "^1.0.0" } }, "sha512-XeV9sDE7ZlOQvs45C9UKMtfTcctcaj/pGwH8YLbMHoMOXNNCn2LsqVQOqrF1+/NU8lKDAqozme9SCXWyo9oAcQ=="],
"parse-entities/character-entities": ["character-entities@1.2.4", "", {}, "sha512-iBMyeEHxfVnIakwOuDXpVkc54HijNgCyQB2w0VfGQThle6NXn50zU6V/u+LDhxHcDUPojn6Kpga3PTAD8W1bQw=="],
"mdast-util-wiki-link/mdast-util-to-markdown/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
"mdast-util-wiki-link/mdast-util-to-markdown/longest-streak": ["longest-streak@2.0.4", "", {}, "sha512-vM6rUVCVUJJt33bnmHiZEvr7wPT78ztX7rojL+LW51bHtLh6HTjx84LA5W4+oa6aKEJA7jJu5LR6vQRBpA5DVg=="],
"mdast-util-wiki-link/mdast-util-to-markdown/mdast-util-to-string": ["mdast-util-to-string@2.0.0", "", {}, "sha512-AW4DRS3QbBayY/jJmD8437V1Gombjf8RSOUCMFBuo5iHi58AGEgVCKQ+ezHkZZDpAQS75hcBMpLqjpJTjtUL7w=="],
"mdast-util-wiki-link/mdast-util-to-markdown/zwitch": ["zwitch@1.0.5", "", {}, "sha512-V50KMwwzqJV0NpZIZFwfOD5/lyny3WlSzRiXgA0G7VUnRlqttta1L6UQIHzd6EuBY/cHGfwTIck7w1yH6Q5zUw=="],
}
}

72
bun.nix
View File

@@ -13,6 +13,10 @@
...
}:
{
"@babel/runtime@7.29.7" = fetchurl {
url = "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz";
hash = "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==";
};
"@biomejs/biome@2.5.3" = fetchurl {
url = "https://registry.npmjs.org/@biomejs/biome/-/biome-2.5.3.tgz";
hash = "sha512-MrJswFdei9EfDwwUy2tQrPDpK0AO+RmMFvBoaaJ6ayBc3sUbHdCE+XG5N8vp+5So41ZupZJQm0roHFFhMGVD7A==";
@@ -75,6 +79,10 @@
url = "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz";
hash = "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==";
};
"@types/unist@2.0.11" = fetchurl {
url = "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz";
hash = "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==";
};
"@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==";
@@ -103,10 +111,22 @@
url = "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz";
hash = "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==";
};
"character-entities-legacy@1.1.4" = fetchurl {
url = "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-1.1.4.tgz";
hash = "sha512-3Xnr+7ZFS1uxeiUDvV02wQ+QDbc55o97tIV5zHScSPJpcLm/r0DFPcoY3tYRp+VZukxuMeKgXYmsXQHO05zQeA==";
};
"character-entities@1.2.4" = fetchurl {
url = "https://registry.npmjs.org/character-entities/-/character-entities-1.2.4.tgz";
hash = "sha512-iBMyeEHxfVnIakwOuDXpVkc54HijNgCyQB2w0VfGQThle6NXn50zU6V/u+LDhxHcDUPojn6Kpga3PTAD8W1bQw==";
};
"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==";
};
"character-reference-invalid@1.1.4" = fetchurl {
url = "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-1.1.4.tgz";
hash = "sha512-mKKUkUbhPpQlCOfIuZkvSEgktjPFIsZKRRbC6KWVEMvlzblj3i3asQv5ODsrwt0N3pHAEvjP8KTQPHkp0+6jOg==";
};
"commander@15.0.0" = fetchurl {
url = "https://registry.npmjs.org/commander/-/commander-15.0.0.tgz";
hash = "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==";
@@ -151,6 +171,22 @@
url = "https://registry.npmjs.org/format/-/format-0.2.2.tgz";
hash = "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==";
};
"is-alphabetical@1.0.4" = fetchurl {
url = "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-1.0.4.tgz";
hash = "sha512-DwzsA04LQ10FHTZuL0/grVDk4rFoVH1pjAToYwBrHSxcrBIGQuXrQMtD5U1b0U2XVgKZCTLLP8u2Qxqhy3l2Vg==";
};
"is-alphanumerical@1.0.4" = fetchurl {
url = "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-1.0.4.tgz";
hash = "sha512-UzoZUr+XfVz3t3v4KyGEniVL9BDRoQtY7tOyrRybkVNjDFWyo1yhXNGrrBTQxp3ib9BLAWs7k2YKBQsFRkZG9A==";
};
"is-decimal@1.0.4" = fetchurl {
url = "https://registry.npmjs.org/is-decimal/-/is-decimal-1.0.4.tgz";
hash = "sha512-RGdriMmQQvZ2aqaQq3awNA6dCGtKpiDFcOzrTWrDAT2MiWrKQVPmxLGHl7Y2nNu6led0kEyoX0enY0qXYsv9zw==";
};
"is-hexadecimal@1.0.4" = fetchurl {
url = "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-1.0.4.tgz";
hash = "sha512-gyPJuv83bHMpocVYoqof5VDiZveEoGoFL8m3BXNb2VW8Xs+rz9kqO8LOQ5DH6EsuvilT1ApazU0pyl+ytbPtlw==";
};
"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==";
@@ -159,6 +195,10 @@
url = "https://registry.npmjs.org/is-unsafe/-/is-unsafe-2.0.0.tgz";
hash = "sha512-2LdV822R+wmI86unXA93WCFpL6g+av8ynWk0nrHyJqGop5VoocYsSLFgN8jrfalT6iGeLNM4KXuVSsULP53kEA==";
};
"longest-streak@2.0.4" = fetchurl {
url = "https://registry.npmjs.org/longest-streak/-/longest-streak-2.0.4.tgz";
hash = "sha512-vM6rUVCVUJJt33bnmHiZEvr7wPT78ztX7rojL+LW51bHtLh6HTjx84LA5W4+oa6aKEJA7jJu5LR6vQRBpA5DVg==";
};
"longest-streak@3.1.0" = fetchurl {
url = "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz";
hash = "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==";
@@ -207,14 +247,26 @@
url = "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz";
hash = "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==";
};
"mdast-util-to-markdown@0.6.5" = fetchurl {
url = "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-0.6.5.tgz";
hash = "sha512-XeV9sDE7ZlOQvs45C9UKMtfTcctcaj/pGwH8YLbMHoMOXNNCn2LsqVQOqrF1+/NU8lKDAqozme9SCXWyo9oAcQ==";
};
"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@2.0.0" = fetchurl {
url = "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-2.0.0.tgz";
hash = "sha512-AW4DRS3QbBayY/jJmD8437V1Gombjf8RSOUCMFBuo5iHi58AGEgVCKQ+ezHkZZDpAQS75hcBMpLqjpJTjtUL7w==";
};
"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==";
};
"mdast-util-wiki-link@0.1.2" = fetchurl {
url = "https://registry.npmjs.org/mdast-util-wiki-link/-/mdast-util-wiki-link-0.1.2.tgz";
hash = "sha512-DTcDyOxKDo3pB3fc0zQlD8myfQjYkW4hazUKI9PUyhtoj9JBeHC2eIdlVXmaT22bZkFAVU2d47B6y2jVKGoUQg==";
};
"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==";
@@ -251,6 +303,10 @@
url = "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz";
hash = "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==";
};
"micromark-extension-wiki-link@0.0.4" = fetchurl {
url = "https://registry.npmjs.org/micromark-extension-wiki-link/-/micromark-extension-wiki-link-0.0.4.tgz";
hash = "sha512-dJc8AfnoU8BHkN+7fWZvIS20SMsMS1ZlxQUn6We67MqeKbOiEDZV5eEvCpwqGBijbJbxX3Kxz879L4K9HIiOvw==";
};
"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==";
@@ -343,6 +399,10 @@
url = "https://registry.npmjs.org/neverthrow/-/neverthrow-8.2.0.tgz";
hash = "sha512-kOCT/1MCPAxY5iUV3wytNFUMUolzuwd/VF/1KCx7kf6CutrOsTie+84zTGTpgQycjvfLdBBdvBvFLqFD2c0wkQ==";
};
"parse-entities@2.0.0" = fetchurl {
url = "https://registry.npmjs.org/parse-entities/-/parse-entities-2.0.0.tgz";
hash = "sha512-kkywGpCcRYhqQIchaWqZ875wzpS/bMKhz5HnN3p7wveJTkTtyAB/AlnS0f8DFSqYW1T82t6yEAkEcB+A1I3MbQ==";
};
"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==";
@@ -363,6 +423,14 @@
url = "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz";
hash = "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==";
};
"remark-wiki-link@2.0.1" = fetchurl {
url = "https://registry.npmjs.org/remark-wiki-link/-/remark-wiki-link-2.0.1.tgz";
hash = "sha512-F8Eut1E7GWfFm4ZDTI6/4ejeZEHZgnVk6E933Yqd/ssYsc4AyI32aGakxwsGcEzbbE7dkWi1EfLlGAdGgOZOsA==";
};
"repeat-string@1.6.1" = fetchurl {
url = "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz";
hash = "sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w==";
};
"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==";
@@ -415,6 +483,10 @@
url = "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz";
hash = "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==";
};
"zwitch@1.0.5" = fetchurl {
url = "https://registry.npmjs.org/zwitch/-/zwitch-1.0.5.tgz";
hash = "sha512-V50KMwwzqJV0NpZIZFwfOD5/lyny3WlSzRiXgA0G7VUnRlqttta1L6UQIHzd6EuBY/cHGfwTIck7w1yH6Q5zUw==";
};
"zwitch@2.0.4" = fetchurl {
url = "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz";
hash = "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==";

View File

@@ -7,6 +7,7 @@ import type {
InlineNode,
ListBlock,
ListItemBlock,
TransclusionNode,
} from "@kitchen-md/core";
import chalk from "chalk";
import { stringify as stringifyYaml } from "yaml";
@@ -101,7 +102,19 @@ function renderInlineNode(node: InlineNode): string {
return chalk.inverse(node.value);
case "link":
return chalk.underline(renderInline(node.children));
case "wikilink":
return chalk.underline(node.display ?? node.target);
case "transclusion":
return renderTransclusion(node);
case "rawInline":
return node.value;
}
}
// A transclusion shows as its raw source text.
// Resolving the embed is out of scope.
function renderTransclusion(node: TransclusionNode): string {
const anchor = node.anchor !== undefined ? `#${node.anchor}` : "";
const display = node.display !== undefined ? `|${node.display}` : "";
return `![[${node.target}${anchor}${display}]]`;
}

View File

@@ -216,6 +216,59 @@ describe("render", () => {
}
});
test("a wikilink renders its display text, or the target when there is none", () => {
const withDisplay = bodyOf({
type: "paragraph",
children: [{ type: "wikilink", target: "basic-brine", anchor: "step", display: "the brine" }],
});
const bare = bodyOf({
type: "paragraph",
children: [{ type: "wikilink", target: "Maple Syrup" }],
});
expect(withDisplay).toBe("the brine\n\n");
expect(bare).toBe("Maple Syrup\n\n");
});
test("a wikilink carries styling distinct from plain text", () => {
const previousLevel = chalk.level;
chalk.level = 1;
try {
const styled = render({
frontmatter: {},
blocks: [{ type: "paragraph", children: [{ type: "wikilink", target: "x" }] }],
diagnostics: [],
});
expect(styled).not.toBe(Bun.stripANSI(styled));
} finally {
chalk.level = previousLevel;
}
});
test("a transclusion renders as its raw source text", () => {
const sectioned = bodyOf({
type: "paragraph",
children: [{ type: "transclusion", target: "italian meatballs", anchor: "rolling:2" }],
});
const headingless = bodyOf({
type: "paragraph",
children: [{ type: "transclusion", target: "basic brine", anchor: "3" }],
});
const aliased = bodyOf({
type: "paragraph",
children: [{ type: "transclusion", target: "recipe", display: "As shown" }],
});
const bare = bodyOf({
type: "paragraph",
children: [{ type: "transclusion", target: "maple syrup" }],
});
expect(sectioned).toBe("![[italian meatballs#rolling:2]]\n\n");
expect(headingless).toBe("![[basic brine#3]]\n\n");
expect(aliased).toBe("![[recipe|As shown]]\n\n");
expect(bare).toBe("![[maple syrup]]\n\n");
});
test("a rawInline node renders its verbatim value", () => {
const stripped = bodyOf({
type: "paragraph",

View File

@@ -13,6 +13,7 @@
"remark-frontmatter": "^5.0.0",
"remark-gfm": "^4.0.1",
"remark-parse": "^11.0.0",
"remark-wiki-link": "^2.0.1",
"unified": "^11.0.5",
"yaml": "^2.9.0"
}

View File

@@ -2,11 +2,42 @@ 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 remarkWikiLink from "remark-wiki-link";
import { unified } from "unified";
import { parse as parseYaml } from "yaml";
import type { Block, DocumentAST, Frontmatter, InlineNode, ListItemBlock } from "./types.ts";
import type {
Block,
DocumentAST,
Frontmatter,
InlineNode,
ListItemBlock,
TransclusionNode,
WikilinkNode,
} from "./types.ts";
const processor = unified().use(remarkParse).use(remarkFrontmatter).use(remarkGfm);
// `|` is the Obsidian alias divider.
// The plugin defaults to `:`, which would swallow anchors like `#rolling:2`.
const processor = unified()
.use(remarkParse)
.use(remarkFrontmatter)
.use(remarkGfm)
.use(remarkWikiLink, { aliasDivider: "|" });
// The mdast node remark-wiki-link injects for `[[…]]`.
// Its `value` is the target with any `#anchor` still attached.
// `data.alias` is the display text, and equals `value` when no alias was written.
interface WikiLinkMdast {
type: "wikiLink";
value: string;
data?: { alias?: string };
}
type InlineMdast = PhrasingContent | WikiLinkMdast;
// Transclusions (`![[…]]`) are not matched by remark-wiki-link — the leading `!`
// makes remark treat the brackets as a failed image, leaving the whole span as
// literal text — so they are recovered by scanning text with this pattern.
const TRANSCLUSION = /!\[\[([^[\]]+)]]/g;
export function parse(input: string): DocumentAST {
const tree = processor.parse(input);
@@ -69,11 +100,11 @@ function translateListItem(item: ListItem, input: string): ListItemBlock {
};
}
function translateInline(nodes: PhrasingContent[], input: string): InlineNode[] {
function translateInline(nodes: InlineMdast[], input: string): InlineNode[] {
return nodes.flatMap((node): InlineNode[] => {
switch (node.type) {
case "text":
return [{ type: "text", value: node.value }];
return splitTransclusions(node.value);
case "emphasis":
return [{ type: "emphasis", children: translateInline(node.children, input) }];
case "strong":
@@ -82,12 +113,64 @@ function translateInline(nodes: PhrasingContent[], input: string): InlineNode[]
return [{ type: "codeSpan", value: node.value }];
case "link":
return [{ type: "link", href: node.url, children: translateInline(node.children, input) }];
case "wikiLink":
return [translateWikilink(node)];
default:
return [{ type: "rawInline", value: slice(node, input) }];
}
});
}
function translateWikilink(node: WikiLinkMdast): WikilinkNode {
const display =
node.data?.alias !== undefined && node.data.alias !== node.value ? node.data.alias : undefined;
return {
type: "wikilink",
...splitAnchor(node.value),
...(display !== undefined ? { display } : {}),
};
}
// Split a text run into plain text and the transclusions embedded in it, preserving order.
// A run with no transclusion yields a single text node.
function splitTransclusions(value: string): InlineNode[] {
const out: InlineNode[] = [];
let cursor = 0;
for (const match of value.matchAll(TRANSCLUSION)) {
const at = match.index;
if (at > cursor) {
out.push({ type: "text", value: value.slice(cursor, at) });
}
out.push(buildTransclusion(match[1]));
cursor = at + match[0].length;
}
if (out.length === 0 || cursor < value.length) {
out.push({ type: "text", value: value.slice(cursor) });
}
return out;
}
function buildTransclusion(inner: string): TransclusionNode {
const pipe = inner.indexOf("|");
const display = pipe === -1 ? undefined : inner.slice(pipe + 1);
const targetPart = pipe === -1 ? inner : inner.slice(0, pipe);
return {
type: "transclusion",
...splitAnchor(targetPart),
...(display !== undefined ? { display } : {}),
};
}
// Split `target#anchor` at the first `#`.
// The anchor is passed through verbatim, and is omitted entirely when absent.
function splitAnchor(value: string): { target: string; anchor?: string } {
const hash = value.indexOf("#");
if (hash === -1) {
return { target: value };
}
return { target: value.slice(0, hash), anchor: value.slice(hash + 1) };
}
// 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 {

View File

@@ -247,6 +247,92 @@ A paragraph under it.
]);
});
test("a bare wikilink carries only its target", () => {
const result = parse("See [[Basic Brine]] first.\n");
const para = result.blocks[0];
if (para?.type !== "paragraph") throw new Error("expected a paragraph block");
expect(para.children).toContainEqual({ type: "wikilink", target: "Basic Brine" });
});
test("a wikilink anchor is passed through verbatim", () => {
const result = parse("[[recipe#the section]]\n");
const para = result.blocks[0];
if (para?.type !== "paragraph") throw new Error("expected a paragraph block");
expect(para.children).toEqual([{ type: "wikilink", target: "recipe", anchor: "the section" }]);
});
test("a wikilink display alias is captured after the pipe", () => {
const result = parse("[[recipe#anchor|Read this]]\n");
const para = result.blocks[0];
if (para?.type !== "paragraph") throw new Error("expected a paragraph block");
expect(para.children).toEqual([
{ type: "wikilink", target: "recipe", anchor: "anchor", display: "Read this" },
]);
});
test("a wikilink and a transclusion are distinct node types sharing one shape", () => {
const result = parse("[[recipe]] versus ![[recipe]]\n");
const para = result.blocks[0];
if (para?.type !== "paragraph") throw new Error("expected a paragraph block");
expect(para.children).toEqual([
{ type: "wikilink", target: "recipe" },
{ type: "text", value: " versus " },
{ type: "transclusion", target: "recipe" },
]);
});
test("a transclusion display alias is captured after the pipe", () => {
const result = parse("![[recipe|As shown]]\n");
const para = result.blocks[0];
if (para?.type !== "paragraph") throw new Error("expected a paragraph block");
expect(para.children).toContainEqual({
type: "transclusion",
target: "recipe",
display: "As shown",
});
});
test("a Step Reference transclusion passes its section anchor through as-is", () => {
const result = parse("![[italian meatballs#rolling:2]]\n");
const para = result.blocks[0];
if (para?.type !== "paragraph") throw new Error("expected a paragraph block");
expect(para.children).toContainEqual({
type: "transclusion",
target: "italian meatballs",
anchor: "rolling:2",
});
});
test("a headingless Step Reference transclusion passes its bare step anchor through as-is", () => {
const result = parse("![[basic brine#3]]\n");
const para = result.blocks[0];
if (para?.type !== "paragraph") throw new Error("expected a paragraph block");
expect(para.children).toContainEqual({
type: "transclusion",
target: "basic brine",
anchor: "3",
});
});
test("a transclusion is extracted from surrounding prose", () => {
const result = parse("Finish with ![[maple syrup#2]] on top.\n");
const para = result.blocks[0];
if (para?.type !== "paragraph") throw new Error("expected a paragraph block");
expect(para.children).toEqual([
{ type: "text", value: "Finish with " },
{ type: "transclusion", target: "maple syrup", anchor: "2" },
{ type: "text", value: " on top." },
]);
});
test("frontmatter is not emitted as a block", () => {
const input = `---
title: X

View File

@@ -34,6 +34,26 @@ export interface LinkNode {
children: InlineNode[];
}
// An Obsidian reference `[[target#anchor|display]]`: target is the filename
// without extension, anchor is the part after `#` verbatim, display the alias after `|`.
// The node type — not a flag — distinguishes it from a transclusion.
export interface WikilinkNode {
type: "wikilink";
target: string;
anchor?: string;
display?: string;
}
// An Obsidian embed `![[target#anchor|display]]`, covering step references like
// `![[file#section:N]]` and `![[file#N]]` whose anchor is passed through as-is.
// It shares WikilinkNode's shape, so the type is what tells the two apart.
export interface TransclusionNode {
type: "transclusion";
target: string;
anchor?: string;
display?: string;
}
// 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 {
@@ -41,7 +61,15 @@ export interface RawInline {
value: string;
}
export type InlineNode = TextNode | EmphasisNode | StrongNode | CodeSpanNode | LinkNode | RawInline;
export type InlineNode =
| TextNode
| EmphasisNode
| StrongNode
| CodeSpanNode
| LinkNode
| WikilinkNode
| TransclusionNode
| RawInline;
export interface HeadingBlock {
type: "heading";