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.
This commit is contained in:
63
.claude/tasks/0005-cross-references.md
Normal file
63
.claude/tasks/0005-cross-references.md
Normal 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.
|
||||
37
bun.lock
37
bun.lock
@@ -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
72
bun.nix
@@ -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==";
|
||||
|
||||
@@ -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}]]`;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user