From b0fdc22165c408f7280e6854d8a844e519b28b2b Mon Sep 17 00:00:00 2001 From: alexion Date: Wed, 29 Jul 2026 20:05:35 -0400 Subject: [PATCH] feat: parse and render Obsidian cross-references (task 0005) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .claude/tasks/0005-cross-references.md | 63 ++++++++++++++++++ bun.lock | 37 +++++++++++ bun.nix | 72 ++++++++++++++++++++ packages/bin/src/render.ts | 13 ++++ packages/bin/src/render_test.ts | 53 +++++++++++++++ packages/core/package.json | 1 + packages/core/src/parse.ts | 91 ++++++++++++++++++++++++-- packages/core/src/parse_test.ts | 86 ++++++++++++++++++++++++ packages/core/src/types.ts | 30 ++++++++- 9 files changed, 441 insertions(+), 5 deletions(-) create mode 100644 .claude/tasks/0005-cross-references.md diff --git a/.claude/tasks/0005-cross-references.md b/.claude/tasks/0005-cross-references.md new file mode 100644 index 0000000..7f2e988 --- /dev/null +++ b/.claude/tasks/0005-cross-references.md @@ -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. diff --git a/bun.lock b/bun.lock index 829454c..483c4e3 100644 --- a/bun.lock +++ b/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=="], } } diff --git a/bun.nix b/bun.nix index bee5af6..470e9f7 100644 --- a/bun.nix +++ b/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=="; diff --git a/packages/bin/src/render.ts b/packages/bin/src/render.ts index b3be206..a445f25 100644 --- a/packages/bin/src/render.ts +++ b/packages/bin/src/render.ts @@ -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}]]`; +} diff --git a/packages/bin/src/render_test.ts b/packages/bin/src/render_test.ts index 5e86b1f..c1ed1a8 100644 --- a/packages/bin/src/render_test.ts +++ b/packages/bin/src/render_test.ts @@ -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", diff --git a/packages/core/package.json b/packages/core/package.json index 4c4e151..89f5479 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -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" } diff --git a/packages/core/src/parse.ts b/packages/core/src/parse.ts index 330d8cd..03579e1 100644 --- a/packages/core/src/parse.ts +++ b/packages/core/src/parse.ts @@ -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 { diff --git a/packages/core/src/parse_test.ts b/packages/core/src/parse_test.ts index ac5b916..af9c019 100644 --- a/packages/core/src/parse_test.ts +++ b/packages/core/src/parse_test.ts @@ -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 diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 12549a9..82f384a 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -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";