From dc4260e147bafa4a036abe00369e92e0de0b8564 Mon Sep 17 00:00:00 2001 From: rm-dr <96270320+rm-dr@users.noreply.github.com> Date: Thu, 6 Nov 2025 21:16:09 -0800 Subject: [PATCH] Add `md-footnote` --- Cargo.lock | 518 +++++++++++++++++- Cargo.toml | 6 + crates/lib/md-dev/Cargo.toml | 15 + crates/lib/md-dev/src/lib.rs | 109 ++++ crates/lib/md-footnote/Cargo.toml | 21 + crates/lib/md-footnote/README.md | 59 ++ crates/lib/md-footnote/src/back_refs.rs | 107 ++++ crates/lib/md-footnote/src/collect.rs | 140 +++++ crates/lib/md-footnote/src/definitions.rs | 179 ++++++ crates/lib/md-footnote/src/inline.rs | 147 +++++ crates/lib/md-footnote/src/lib.rs | 89 +++ crates/lib/md-footnote/src/references.rs | 108 ++++ crates/lib/md-footnote/tests/fixtures.rs | 15 + crates/lib/md-footnote/tests/fixtures/0.md | 31 ++ crates/lib/md-footnote/tests/fixtures/1.md | 45 ++ crates/lib/md-footnote/tests/fixtures/10.md | 19 + crates/lib/md-footnote/tests/fixtures/2.md | 27 + crates/lib/md-footnote/tests/fixtures/3.md | 23 + crates/lib/md-footnote/tests/fixtures/4.md | 14 + crates/lib/md-footnote/tests/fixtures/5.md | 19 + crates/lib/md-footnote/tests/fixtures/6.md | 28 + crates/lib/md-footnote/tests/fixtures/8.md | 19 + crates/lib/md-footnote/tests/fixtures/9.md | 37 ++ crates/lib/md-footnote/tests/fixtures/_7.md | 24 + .../md-footnote/tests/fixtures/inline-1.md | 21 + .../md-footnote/tests/fixtures/inline-2.md | 17 + .../md-footnote/tests/fixtures/inline-3.md | 19 + .../md-footnote/tests/fixtures/inline-4.md | 19 + 28 files changed, 1868 insertions(+), 7 deletions(-) create mode 100644 crates/lib/md-dev/Cargo.toml create mode 100644 crates/lib/md-dev/src/lib.rs create mode 100644 crates/lib/md-footnote/Cargo.toml create mode 100644 crates/lib/md-footnote/README.md create mode 100644 crates/lib/md-footnote/src/back_refs.rs create mode 100644 crates/lib/md-footnote/src/collect.rs create mode 100644 crates/lib/md-footnote/src/definitions.rs create mode 100644 crates/lib/md-footnote/src/inline.rs create mode 100644 crates/lib/md-footnote/src/lib.rs create mode 100644 crates/lib/md-footnote/src/references.rs create mode 100644 crates/lib/md-footnote/tests/fixtures.rs create mode 100644 crates/lib/md-footnote/tests/fixtures/0.md create mode 100644 crates/lib/md-footnote/tests/fixtures/1.md create mode 100644 crates/lib/md-footnote/tests/fixtures/10.md create mode 100644 crates/lib/md-footnote/tests/fixtures/2.md create mode 100644 crates/lib/md-footnote/tests/fixtures/3.md create mode 100644 crates/lib/md-footnote/tests/fixtures/4.md create mode 100644 crates/lib/md-footnote/tests/fixtures/5.md create mode 100644 crates/lib/md-footnote/tests/fixtures/6.md create mode 100644 crates/lib/md-footnote/tests/fixtures/8.md create mode 100644 crates/lib/md-footnote/tests/fixtures/9.md create mode 100644 crates/lib/md-footnote/tests/fixtures/_7.md create mode 100644 crates/lib/md-footnote/tests/fixtures/inline-1.md create mode 100644 crates/lib/md-footnote/tests/fixtures/inline-2.md create mode 100644 crates/lib/md-footnote/tests/fixtures/inline-3.md create mode 100644 crates/lib/md-footnote/tests/fixtures/inline-4.md diff --git a/Cargo.lock b/Cargo.lock index fb751f7..d5d329a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -143,6 +143,17 @@ checksum = "3f8ebf5827e4ac4fd5946560e6a99776ea73b596d80898f357007317a7141e47" name = "assetserver" version = "0.0.1" +[[package]] +name = "ast_node" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2eb025ef00a6da925cf40870b9c8d008526b6004ece399cb0974209720f0b194" +dependencies = [ + "quote", + "swc_macros_common", + "syn 2.0.108", +] + [[package]] name = "async-compression" version = "0.4.32" @@ -239,6 +250,15 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "better_scoped_tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd228125315b132eed175bf47619ac79b945b26e56b848ba203ae4ea8603609" +dependencies = [ + "scoped-tls", +] + [[package]] name = "bincode" version = "1.3.3" @@ -311,6 +331,48 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" +[[package]] +name = "bytes-str" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c60b5ce37e0b883c37eb89f79a1e26fbe9c1081945d024eee93e8d91a7e18b3" +dependencies = [ + "bytes", + "serde", +] + +[[package]] +name = "camino" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "276a59bf2b2c967788139340c9f0c5b12d7fd6630315c15c217e559de85d2609" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cargo-platform" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d886547e41f740c616ae73108f6eb70afe6d940c7bc697cb30f13daec073037" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 1.0.69", +] + [[package]] name = "cc" version = "1.2.44" @@ -553,6 +615,18 @@ dependencies = [ "syn 2.0.108", ] +[[package]] +name = "diff" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" + +[[package]] +name = "difference" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524cbf6897b527295dff137cec09ecf3a05f4fddffd7dfcd1585403449e74198" + [[package]] name = "digest" version = "0.10.7" @@ -563,6 +637,27 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "dirs-next" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b98cf8ebf19c3d1b223e151f99a4f9f0690dca41414773390fc824184ac833e1" +dependencies = [ + "cfg-if", + "dirs-sys-next", +] + +[[package]] +name = "dirs-sys-next" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" +dependencies = [ + "libc", + "redox_users", + "winapi", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -610,6 +705,12 @@ dependencies = [ "phf 0.13.1", ] +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + [[package]] name = "encoding_rs" version = "0.8.35" @@ -689,6 +790,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "from_variant" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5ff35a391aef949120a0340d690269b3d9f63460a6106e99bd07b961f345ea9" +dependencies = [ + "swc_macros_common", + "syn 2.0.108", +] + [[package]] name = "futures-channel" version = "0.3.31" @@ -788,6 +899,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + [[package]] name = "grass" version = "0.13.4" @@ -859,6 +976,26 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hstr" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c43c0a9e8fbdb3bb9dc8eee85e1e2ac81605418b4c83b6b7413cbf14d56ca5c" +dependencies = [ + "hashbrown 0.14.5", + "new_debug_unreachable", + "once_cell", + "rustc-hash", + "serde", + "triomphe", +] + [[package]] name = "html-escape" version = "0.2.13" @@ -1142,6 +1279,17 @@ dependencies = [ "serde", ] +[[package]] +name = "is-terminal" +version = "0.4.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" +dependencies = [ + "hermit-abi", + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -1204,6 +1352,16 @@ version = "0.2.177" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" +[[package]] +name = "libredox" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb" +dependencies = [ + "bitflags", + "libc", +] + [[package]] name = "libservice" version = "0.0.1" @@ -1371,6 +1529,22 @@ dependencies = [ "syn 2.0.108", ] +[[package]] +name = "md-dev" +version = "0.2.0" +dependencies = [ + "prettydiff", +] + +[[package]] +name = "md-footnote" +version = "0.2.0" +dependencies = [ + "markdown-it", + "md-dev", + "testing", +] + [[package]] name = "mdurl" version = "0.3.1" @@ -1388,6 +1562,30 @@ version = "2.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" +[[package]] +name = "miette" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7" +dependencies = [ + "cfg-if", + "miette-derive", + "owo-colors", + "textwrap", + "unicode-width 0.1.14", +] + +[[package]] +name = "miette-derive" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.108", +] + [[package]] name = "mime" version = "0.3.17" @@ -1442,6 +1640,12 @@ dependencies = [ "version_check", ] +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -1551,6 +1755,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "owo-colors" +version = "4.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c6901729fa79e91a0913333229e9ca5dc725089d1c363b2f4b4760709dc4a52" + [[package]] name = "page" version = "0.0.1" @@ -1643,7 +1853,7 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" dependencies = [ - "siphasher", + "siphasher 1.0.1", ] [[package]] @@ -1652,7 +1862,7 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" dependencies = [ - "siphasher", + "siphasher 1.0.1", ] [[package]] @@ -1710,6 +1920,39 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "pretty_assertions" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" +dependencies = [ + "diff", + "yansi", +] + +[[package]] +name = "prettydiff" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac17546d82912e64874e3d5b40681ce32eac4e5834344f51efcf689ff1550a65" +dependencies = [ + "owo-colors", + "prettytable-rs", +] + +[[package]] +name = "prettytable-rs" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eea25e07510aa6ab6547308ebe3c036016d162b8da920dbb079e3ba8acf3d95a" +dependencies = [ + "encode_unicode", + "is-terminal", + "lazy_static", + "term", + "unicode-width 0.1.14", +] + [[package]] name = "proc-macro2" version = "1.0.103" @@ -1812,7 +2055,7 @@ dependencies = [ "rustc-hash", "rustls", "socket2", - "thiserror", + "thiserror 2.0.17", "tokio", "tracing", "web-time", @@ -1833,7 +2076,7 @@ dependencies = [ "rustls", "rustls-pki-types", "slab", - "thiserror", + "thiserror 2.0.17", "tinyvec", "tracing", "web-time", @@ -1947,6 +2190,17 @@ dependencies = [ "bitflags", ] +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.16", + "libredox", + "thiserror 1.0.69", +] + [[package]] name = "regex" version = "1.12.2" @@ -1976,6 +2230,12 @@ version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" +[[package]] +name = "relative-path" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2" + [[package]] name = "reqwest" version = "0.12.24" @@ -2143,6 +2403,12 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + [[package]] name = "scopeguard" version = "1.2.0" @@ -2154,6 +2420,10 @@ name = "semver" version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" +dependencies = [ + "serde", + "serde_core", +] [[package]] name = "serde" @@ -2244,6 +2514,7 @@ dependencies = [ "macro-sass", "markdown-it", "maud", + "md-footnote", "page", "parking_lot", "reqwest", @@ -2297,6 +2568,12 @@ version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" +[[package]] +name = "siphasher" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" + [[package]] name = "siphasher" version = "1.0.1" @@ -2389,6 +2666,90 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "swc_atoms" +version = "9.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4ccbe2ecad10ad7432100f878a107b1d972a8aee83ca53184d00c23a078bb8a" +dependencies = [ + "hstr", + "once_cell", + "serde", +] + +[[package]] +name = "swc_common" +version = "17.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77d9476c82da5448b227042b1e695fbe0456e7d749567e0d4ec7ac128f1e019d" +dependencies = [ + "anyhow", + "ast_node", + "better_scoped_tls", + "bytes-str", + "either", + "from_variant", + "new_debug_unreachable", + "num-bigint", + "once_cell", + "parking_lot", + "rustc-hash", + "serde", + "siphasher 0.3.11", + "swc_atoms", + "swc_eq_ignore_macros", + "swc_visit", + "termcolor", + "tracing", + "unicode-width 0.2.2", + "url", +] + +[[package]] +name = "swc_eq_ignore_macros" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c16ce73424a6316e95e09065ba6a207eba7765496fed113702278b7711d4b632" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.108", +] + +[[package]] +name = "swc_error_reporters" +version = "19.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30c41e7b4f78298094092765ddf5b667491026a53a1d149c25b983188d471cbc" +dependencies = [ + "anyhow", + "miette", + "once_cell", + "serde", + "swc_common", +] + +[[package]] +name = "swc_macros_common" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aae1efbaa74943dc5ad2a2fb16cbd78b77d7e4d63188f3c5b4df2b4dcd2faaae" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.108", +] + +[[package]] +name = "swc_visit" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62fb71484b486c185e34d2172f0eabe7f4722742aad700f426a494bb2de232a2" +dependencies = [ + "either", + "new_debug_unreachable", +] + [[package]] name = "syn" version = "1.0.109" @@ -2447,18 +2808,105 @@ dependencies = [ "serde", "serde_derive", "serde_json", - "thiserror", + "thiserror 2.0.17", "walkdir", "yaml-rust", ] +[[package]] +name = "term" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c59df8ac95d96ff9bede18eb7300b0fda5e5d8d90960e76f8e14ae765eedbf1f" +dependencies = [ + "dirs-next", + "rustversion", + "winapi", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "testing" +version = "18.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77e633123aa8ec1da20243f9eb885e55666f1182d451d6a5372d879f2f272aad" +dependencies = [ + "cargo_metadata", + "difference", + "once_cell", + "pretty_assertions", + "regex", + "rustc-hash", + "serde", + "serde_json", + "swc_common", + "swc_error_reporters", + "testing_macros", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "testing_macros" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7442bd3ca09f38d4788dc5ebafbc1967c3717726b4b074db011d470b353548b" +dependencies = [ + "anyhow", + "glob", + "once_cell", + "proc-macro2", + "quote", + "regex", + "relative-path", + "syn 2.0.108", +] + +[[package]] +name = "textwrap" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" +dependencies = [ + "unicode-linebreak", + "unicode-width 0.2.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.17", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.108", ] [[package]] @@ -2650,7 +3098,7 @@ dependencies = [ "envy", "num", "serde", - "thiserror", + "thiserror 2.0.17", "tokio", "tracing", "tracing-loki", @@ -2805,6 +3253,16 @@ dependencies = [ "tracing-serde", ] +[[package]] +name = "triomphe" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd69c5aa8f924c7519d6372789a74eac5b94fb0f8fcf0d4a97eb0bfc3e785f39" +dependencies = [ + "serde", + "stable_deref_trait", +] + [[package]] name = "try-lock" version = "0.2.5" @@ -2841,6 +3299,12 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +[[package]] +name = "unicode-linebreak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" + [[package]] name = "unicode-normalization" version = "0.1.25" @@ -2850,6 +3314,18 @@ dependencies = [ "tinyvec", ] +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "unicode-xid" version = "0.2.6" @@ -3101,6 +3577,22 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + [[package]] name = "winapi-util" version = "0.1.11" @@ -3110,6 +3602,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-core" version = "0.62.2" @@ -3361,6 +3859,12 @@ dependencies = [ "linked-hash-map", ] +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + [[package]] name = "yoke" version = "0.8.1" diff --git a/Cargo.toml b/Cargo.toml index fded44b..d203286 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -70,6 +70,8 @@ assetserver = { path = "crates/lib/assetserver" } libservice = { path = "crates/lib/libservice" } toolbox = { path = "crates/lib/toolbox" } page = { path = "crates/lib/page" } +md-footnote = { path = "crates/lib/md-footnote" } +md-dev = { path = "crates/lib/md-dev" } service-webpage = { path = "crates/service/service-webpage" } @@ -143,6 +145,10 @@ lru = "0.16.2" parking_lot = "0.12.5" lazy_static = "1.5.0" +# md_* test utilities +prettydiff = "0.9.0" +testing = "18.0.0" + # # Macro utilities # diff --git a/crates/lib/md-dev/Cargo.toml b/crates/lib/md-dev/Cargo.toml new file mode 100644 index 0000000..ee76101 --- /dev/null +++ b/crates/lib/md-dev/Cargo.toml @@ -0,0 +1,15 @@ +# Clone of +# https://github.com/markdown-it-rust/markdown-it-plugins.rs + +[package] +name = "md-dev" +version = "0.2.0" +publish = false +rust-version = { workspace = true } +edition = { workspace = true } + +[lints] +workspace = true + +[dependencies] +prettydiff = { workspace = true } diff --git a/crates/lib/md-dev/src/lib.rs b/crates/lib/md-dev/src/lib.rs new file mode 100644 index 0000000..2f2e81e --- /dev/null +++ b/crates/lib/md-dev/src/lib.rs @@ -0,0 +1,109 @@ +//! development utilities +//! +//! This contains shared code for reading test fixtures, +//! testing for differences, and regenerating expected output. + +use prettydiff::diff_lines; +use std::path::PathBuf; + +pub struct FixtureFile { + pub file: PathBuf, + pub title: String, + pub input: String, + pub expected: String, +} + +/// Read a fixture file into a FixtureFile struct +pub fn read_fixture_file(file: PathBuf) -> FixtureFile { + #[expect(clippy::unwrap_used)] + let text = std::fs::read_to_string(&file).unwrap(); + + let mut lines = text.lines(); + let mut title = String::new(); + let mut input = String::new(); + let mut expected = String::new(); + loop { + match lines.next() { + None => panic!("no '....' line found to signal start of input"), + Some(line) if line.starts_with("....") => break, + Some(line) => { + title.push_str(line); + title.push('\n'); + } + } + } + loop { + match lines.next() { + None => panic!("no '....' line found to signal start of expected output"), + Some(line) if line.starts_with("....") => break, + Some(line) => { + input.push_str(line); + input.push('\n'); + } + } + } + loop { + match lines.next() { + None => break, + Some(line) => { + expected.push_str(line); + expected.push('\n'); + } + } + } + // strip preceding empty line in input + while input.starts_with('\n') { + input = input[1..].to_string(); + } + // strip trailing empty lines from input + while input.ends_with('\n') { + input.pop(); + } + // strip preceding empty line in expected + while expected.starts_with('\n') { + expected = expected[1..].to_string(); + } + + FixtureFile { + file, + title, + input, + expected, + } +} + +/// Assert that the actual output matches the expected output, +/// and panic with a diff if it does not. +pub fn assert_no_diff(f: FixtureFile, actual: &str) { + if actual.trim_end() != f.expected.trim_end() { + let diff = diff_lines(&f.expected, actual); + + // if environmental variable FORCE_REGEN is set, overwrite the expected output + if std::env::var("FORCE_REGEN").is_ok() { + let written = std::fs::write( + f.file, + format!( + "{}\n......\n\n{}\n\n......\n\n{}\n", + f.title.trim_end(), + f.input, + actual.trim_end() + ), + ) + .is_ok(); + if written { + panic!( + "\n{}\nDiff:\n{}\n\nRegenerated expected output", + f.title, diff + ); + } + panic!( + "\n{}\nDiff:\n{}\n\nFailed to regenerate expected output", + f.title, diff + ) + } + panic!( + "\n{}\nDiff:\n{}\nSet FORCE_REGEN=true to update fixture", + f.title, diff + ); + } +} diff --git a/crates/lib/md-footnote/Cargo.toml b/crates/lib/md-footnote/Cargo.toml new file mode 100644 index 0000000..4026c84 --- /dev/null +++ b/crates/lib/md-footnote/Cargo.toml @@ -0,0 +1,21 @@ +# Clone of +# https://github.com/markdown-it-rust/markdown-it-plugins.rs + +[package] +name = "md-footnote" +version = "0.2.0" +description = "A markdown-it plugin for parsing footnotes" +readme = "README.md" +license = "Apache-2.0" +rust-version = { workspace = true } +edition = { workspace = true } + +[lints] +workspace = true + +[dependencies] +markdown-it = { workspace = true } + +[dev-dependencies] +md-dev = { workspace = true } +testing = { workspace = true } diff --git a/crates/lib/md-footnote/README.md b/crates/lib/md-footnote/README.md new file mode 100644 index 0000000..1a27340 --- /dev/null +++ b/crates/lib/md-footnote/README.md @@ -0,0 +1,59 @@ +# markdown-it-footnote.rs + +[crates.io](https://crates.io/crates/markdown-it-footnote) + +A [markdown-it.rs](https://crates.io/crates/markdown-it) plugin to process footnotes. + +It is based on the [pandoc definition](http://johnmacfarlane.net/pandoc/README.html#footnotes): + +```md +Normal footnote: + +Here is a footnote reference,[^1] and another.[^longnote] + +Here is an inline note.^[my note is here!] + +[^1]: Here is the footnote. + +[^longnote]: Here's one with multiple blocks. + + Subsequent paragraphs are indented to show that they +belong to the previous footnote. +``` + +See the [tests](tests/fixtures) for more examples. + +## Usage + +To load the full plugin: + +```rust +let parser = &mut markdown_it::MarkdownIt::new(); +markdown_it::plugins::cmark::add(parser); + +md_footnote::add(parser); + +let ast = parser.parse("Example^[my note]"); +let html = ast.render(); +``` + +Alternatively, you can load the separate components: + +```rust +let parser = &mut markdown_it::MarkdownIt::new(); +markdown_it::plugins::cmark::add(parser); + +md_footnote::definitions::add(md); +md_footnote::references::add(md); +md_footnote::inline::add(md); +md_footnote::collect::add(md); +md_footnote::back_refs::add(md); +``` + +Which have the following roles: + +- `definitions`: parse footnote definitions, e.g. `[^1]: foo` +- `references`: parse footnote references, e.g. `[^1]` +- `inline`: parse inline footnotes, e.g. `^[foo]` +- `collect`: collect footnote definitions (removing duplicate/unreferenced ones) and move them to be the last child of the root node. +- `back_refs`: add anchor(s) to footnote definitions, with links back to the reference(s) diff --git a/crates/lib/md-footnote/src/back_refs.rs b/crates/lib/md-footnote/src/back_refs.rs new file mode 100644 index 0000000..060ff02 --- /dev/null +++ b/crates/lib/md-footnote/src/back_refs.rs @@ -0,0 +1,107 @@ +//! Plugin to add anchor(s) to footnote definitions, +//! with links back to the reference(s). +//! +//! ```rust +//! let parser = &mut markdown_it::MarkdownIt::new(); +//! markdown_it::plugins::cmark::add(parser); +//! md_footnote::references::add(parser); +//! md_footnote::definitions::add(parser); +//! md_footnote::back_refs::add(parser); +//! let root = parser.parse("[^label]\n\n[^label]: This is a footnote"); +//! let mut names = vec![]; +//! root.walk(|node,_| { names.push(node.name()); }); +//! assert_eq!(names, vec![ +//! "markdown_it::parser::core::root::Root", +//! "markdown_it::plugins::cmark::block::paragraph::Paragraph", +//! "md_footnote::references::FootnoteReference", +//! "md_footnote::definitions::FootnoteDefinition", +//! "markdown_it::plugins::cmark::block::paragraph::Paragraph", +//! "markdown_it::parser::inline::builtin::skip_text::Text", +//! "md_footnote::back_refs::FootnoteRefAnchor", +//! ]); +//! ``` +use markdown_it::{ + MarkdownIt, Node, NodeValue, + parser::core::{CoreRule, Root}, + plugins::cmark::block::paragraph::Paragraph, +}; + +use crate::{FootnoteMap, definitions::FootnoteDefinition}; + +pub fn add(md: &mut MarkdownIt) { + // insert this rule into parser + md.add_rule::(); +} + +#[derive(Debug)] +pub struct FootnoteRefAnchor { + pub ref_ids: Vec, +} +impl NodeValue for FootnoteRefAnchor { + fn render(&self, _: &Node, fmt: &mut dyn markdown_it::Renderer) { + for ref_id in self.ref_ids.iter() { + fmt.text(" "); + fmt.open( + "a", + &[ + ("href", format!("#fnref{}", ref_id)), + ("class", String::from("footnote-backref")), + ], + ); + // # ↩ with escape code to prevent display as Apple Emoji on iOS + fmt.text("\u{21a9}\u{FE0E}"); + fmt.close("a"); + } + } +} + +// This is an extension for the markdown parser. +struct FootnoteBackrefRule; + +impl CoreRule for FootnoteBackrefRule { + fn run(root: &mut Node, _: &MarkdownIt) { + // TODO this seems very cumbersome + // but it is also how the markdown_it::InlineParserRule works + #[expect(clippy::unwrap_used)] + let data = root.cast_mut::().unwrap(); + + let root_ext = std::mem::take(&mut data.ext); + let map = match root_ext.get::() { + Some(map) => map, + None => return, + }; + + // walk through the AST and add backref anchors to footnote definitions + root.walk_mut(|node, _| { + if let Some(def_node) = node.cast::() { + let ref_ids = { + match def_node.def_id { + Some(def_id) => map.referenced_by(def_id), + None => Vec::new(), + } + }; + if !ref_ids.is_empty() { + // if the final child is a paragraph node, + // append the anchor to its children, + // otherwise simply append to the end of the node children + match node.children.last_mut() { + Some(last) => { + if last.is::() { + last.children.push(Node::new(FootnoteRefAnchor { ref_ids })); + } else { + node.children.push(Node::new(FootnoteRefAnchor { ref_ids })); + } + } + _ => { + node.children.push(Node::new(FootnoteRefAnchor { ref_ids })); + } + } + } + } + }); + + #[expect(clippy::unwrap_used)] + let data = root.cast_mut::().unwrap(); + data.ext = root_ext; + } +} diff --git a/crates/lib/md-footnote/src/collect.rs b/crates/lib/md-footnote/src/collect.rs new file mode 100644 index 0000000..03a45c7 --- /dev/null +++ b/crates/lib/md-footnote/src/collect.rs @@ -0,0 +1,140 @@ +//! Plugin to collect footnote definitions, +//! removing duplicate/unreferenced ones, +//! and move them to be the last child of the root node. +//! +//! ```rust +//! let parser = &mut markdown_it::MarkdownIt::new(); +//! markdown_it::plugins::cmark::add(parser); +//! md_footnote::references::add(parser); +//! md_footnote::definitions::add(parser); +//! md_footnote::collect::add(parser); +//! let root = parser.parse("[^label]\n\n[^label]: This is a footnote\n\n> quote"); +//! let mut names = vec![]; +//! root.walk(|node,_| { names.push(node.name()); }); +//! assert_eq!(names, vec![ +//! "markdown_it::parser::core::root::Root", +//! "markdown_it::plugins::cmark::block::paragraph::Paragraph", +//! "md_footnote::references::FootnoteReference", +//! "markdown_it::plugins::cmark::block::blockquote::Blockquote", +//! "markdown_it::plugins::cmark::block::paragraph::Paragraph", +//! "markdown_it::parser::inline::builtin::skip_text::Text", +//! "md_footnote::collect::FootnotesContainerNode", +//! "md_footnote::definitions::FootnoteDefinition", +//! "markdown_it::plugins::cmark::block::paragraph::Paragraph", +//! "markdown_it::parser::inline::builtin::skip_text::Text", +//! ]); +//! ``` +use markdown_it::{ + MarkdownIt, Node, NodeValue, + parser::core::{CoreRule, Root}, + plugins::cmark::block::paragraph::Paragraph, +}; + +use crate::{FootnoteMap, definitions::FootnoteDefinition}; + +pub fn add(md: &mut MarkdownIt) { + // insert this rule into parser + md.add_rule::(); +} + +#[derive(Debug)] +struct PlaceholderNode; +impl NodeValue for PlaceholderNode {} + +#[derive(Debug)] +pub struct FootnotesContainerNode; +impl NodeValue for FootnotesContainerNode { + fn render(&self, node: &Node, fmt: &mut dyn markdown_it::Renderer) { + let mut attrs = node.attrs.clone(); + attrs.push(("class", "footnotes".into())); + fmt.cr(); + fmt.self_close("hr", &[("class", "footnotes-sep".into())]); + fmt.cr(); + fmt.open("section", &attrs); + fmt.cr(); + fmt.open("ol", &[("class", "footnotes-list".into())]); + fmt.cr(); + fmt.contents(&node.children); + fmt.cr(); + fmt.close("ol"); + fmt.cr(); + fmt.close("section"); + fmt.cr(); + } +} + +// This is an extension for the markdown parser. +struct FootnoteCollectRule; + +impl CoreRule for FootnoteCollectRule { + // This is a custom function that will be invoked once per document. + // + // It has `root` node of the AST as an argument and may modify its + // contents as you like. + // + fn run(root: &mut Node, _: &MarkdownIt) { + // TODO this seems very cumbersome + // but it is also how the markdown_it::InlineParserRule works + #[expect(clippy::unwrap_used)] + let data = root.cast_mut::().unwrap(); + + let root_ext = std::mem::take(&mut data.ext); + let map = match root_ext.get::() { + Some(map) => map, + None => return, + }; + + // walk through the AST and extract all footnote definitions + let mut defs = vec![]; + root.walk_mut(|node, _| { + // TODO could use drain_filter if it becomes stable: https://github.com/rust-lang/rust/issues/43244 + // defs.extend( + // node.children + // .drain_filter(|child| !child.is::()) + // .collect(), + // ); + + for child in node.children.iter_mut() { + if child.is::() { + let mut extracted = std::mem::replace(child, Node::new(PlaceholderNode)); + match extracted.cast::() { + Some(def_node) => { + // skip footnotes that are not referenced + match def_node.def_id { + Some(def_id) => { + if map.referenced_by(def_id).is_empty() { + continue; + } + } + None => continue, + } + if def_node.inline { + // for inline footnotes, + // we need to wrap the definition's children in a paragraph + let mut para = Node::new(Paragraph); + std::mem::swap(&mut para.children, &mut extracted.children); + extracted.children = vec![para]; + } + } + None => continue, + } + defs.push(extracted); + } + } + node.children.retain(|child| !child.is::()); + }); + if defs.is_empty() { + return; + } + + // wrap the definitions in a container and append them to the root + let mut wrapper = Node::new(FootnotesContainerNode); + wrapper.children = defs; + root.children.push(wrapper); + + #[expect(clippy::unwrap_used)] + let data = root.cast_mut::().unwrap(); + + data.ext = root_ext; + } +} diff --git a/crates/lib/md-footnote/src/definitions.rs b/crates/lib/md-footnote/src/definitions.rs new file mode 100644 index 0000000..38b97f4 --- /dev/null +++ b/crates/lib/md-footnote/src/definitions.rs @@ -0,0 +1,179 @@ +//! Plugin to parse footnote definitions +//! +//! ```rust +//! let parser = &mut markdown_it::MarkdownIt::new(); +//! markdown_it::plugins::cmark::add(parser); +//! md_footnote::definitions::add(parser); +//! let root = parser.parse("[^label]: This is a footnote"); +//! let mut names = vec![]; +//! root.walk(|node,_| { names.push(node.name()); }); +//! assert_eq!(names, vec![ +//! "markdown_it::parser::core::root::Root", +//! "md_footnote::definitions::FootnoteDefinition", +//! "markdown_it::plugins::cmark::block::paragraph::Paragraph", +//! "markdown_it::parser::inline::builtin::skip_text::Text", +//! ]); +//! ``` + +use markdown_it::parser::block::{BlockRule, BlockState}; +use markdown_it::plugins::cmark::block::reference::ReferenceScanner; +use markdown_it::{MarkdownIt, Node, NodeValue, Renderer}; + +use crate::FootnoteMap; + +/// Add the footnote definition plugin to the parser +pub fn add(md: &mut MarkdownIt) { + // insert this rule into block subparser + md.block + .add_rule::() + .before::(); +} + +#[derive(Debug)] +/// AST node for footnote definition +pub struct FootnoteDefinition { + pub label: Option, + pub def_id: Option, + pub inline: bool, +} + +impl NodeValue for FootnoteDefinition { + fn render(&self, node: &Node, fmt: &mut dyn Renderer) { + let mut attrs = node.attrs.clone(); + if let Some(def_id) = self.def_id { + attrs.push(("id", format!("fn{}", def_id))); + } + attrs.push(("class", "footnote-item".into())); + + fmt.cr(); + fmt.open("li", &attrs); + fmt.contents(&node.children); + fmt.close("li"); + fmt.cr(); + } +} + +/// An extension for the block subparser. +struct FootnoteDefinitionScanner; + +impl FootnoteDefinitionScanner { + fn is_def(state: &mut BlockState<'_, '_>) -> Option<(String, usize)> { + if state.line_indent(state.line) >= state.md.max_indent { + return None; + } + + let mut chars = state.get_line(state.line).chars(); + + // check line starts with the correct syntax + let Some('[') = chars.next() else { + return None; + }; + let Some('^') = chars.next() else { + return None; + }; + + // gather the label + let mut label = String::new(); + // The labels in footnote references may not contain spaces, tabs, or newlines. + // Backslash escapes form part of the label and do not escape anything + loop { + match chars.next() { + None => return None, + Some(']') => { + if let Some(':') = chars.next() { + break; + } else { + return None; + } + } + Some(' ') => return None, + Some(c) => label.push(c), + } + } + if label.is_empty() { + return None; + } + // get number of spaces to next non-space character + let mut spaces = 0; + loop { + match chars.next() { + None => break, + Some(' ') => spaces += 1, + Some('\t') => spaces += 1, // spaces += 4 - spaces % 4, + Some(_) => break, + } + } + Some((label, spaces)) + } +} + +impl BlockRule for FootnoteDefinitionScanner { + fn check(state: &mut BlockState<'_, '_>) -> Option<()> { + // can interrupt a block elements, + // but only if its a child of another footnote definition + // TODO I think strictly only paragraphs should be interrupted, but this is not yet possible in markdown-it.rs + if state.node.is::() && Self::is_def(state).is_some() { + return Some(()); + } + None + } + + fn run(state: &mut BlockState<'_, '_>) -> Option<(Node, usize)> { + let (label, spaces) = Self::is_def(state)?; + + // record the footnote label, so we can match references to it later + let foot_map = state.root_ext.get_or_insert_default::(); + let def_id = foot_map.add_def(&label); + + // temporarily set the current node to the footnote definition + // so child nodes are added to it + let new_node = Node::new(FootnoteDefinition { + label: Some(label.clone()), + def_id, + inline: false, + }); + let old_node = std::mem::replace(&mut state.node, new_node); + + // store the current line and its offsets, so we can restore later + let first_line = state.line; + let first_line_offsets = state.line_offsets[first_line].clone(); + + // temporarily change the first line offsets to account for the footnote label + // TODO this is not quite the same as pandoc where spaces >= 8 is code block (here >= 4) + state.line_offsets[first_line].first_nonspace += "[^]:".len() + label.len() + spaces; + state.line_offsets[first_line].indent_nonspace += "[^]:".len() as i32 + spaces as i32; + // tokenize with a +4 space indent + state.blk_indent += 4; + state.md.block.tokenize(state); + state.blk_indent -= 4; + + // get the number of lines the footnote definition occupies + let num_lines = state.line - first_line; + + // restore the first line and its offsets + state.line_offsets[first_line] = first_line_offsets; + state.line = first_line; + + // restore the original node and return the footnote and number of lines it occupies + Some((std::mem::replace(&mut state.node, old_node), num_lines)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn it_works() { + let parser = &mut markdown_it::MarkdownIt::new(); + markdown_it::plugins::cmark::add(parser); + markdown_it::plugins::sourcepos::add(parser); + add(parser); + let node = parser.parse("[^note]: a\n\nhallo\nthere\n"); + // println!("{:#?}", node); + assert!(node.children.first().unwrap().is::()); + + // let text = node.render(); + // assert_eq!(text, "hallo\n") + } +} diff --git a/crates/lib/md-footnote/src/inline.rs b/crates/lib/md-footnote/src/inline.rs new file mode 100644 index 0000000..33fada5 --- /dev/null +++ b/crates/lib/md-footnote/src/inline.rs @@ -0,0 +1,147 @@ +//! Plugin to parse inline footnotes +//! +//! ```rust +//! let parser = &mut markdown_it::MarkdownIt::new(); +//! markdown_it::plugins::cmark::add(parser); +//! md_footnote::inline::add(parser); +//! let root = parser.parse("Example^[This is a footnote]"); +//! let mut names = vec![]; +//! root.walk(|node,_| { names.push(node.name()); }); +//! assert_eq!(names, vec![ +//! "markdown_it::parser::core::root::Root", +//! "markdown_it::plugins::cmark::block::paragraph::Paragraph", +//! "markdown_it::parser::inline::builtin::skip_text::Text", +//! "md_footnote::inline::InlineFootnote", +//! "md_footnote::definitions::FootnoteDefinition", +//! "markdown_it::parser::inline::builtin::skip_text::Text", +//! "md_footnote::references::FootnoteReference" +//! ]); +//! ``` +use markdown_it::{ + MarkdownIt, Node, NodeValue, + parser::inline::{InlineRule, InlineState}, +}; + +use crate::{FootnoteMap, definitions::FootnoteDefinition}; + +/// Add the inline footnote plugin to the parser +pub fn add(md: &mut MarkdownIt) { + // insert this rule into inline subparser + md.inline.add_rule::(); +} + +#[derive(Debug)] +pub struct InlineFootnote; +impl NodeValue for InlineFootnote { + fn render(&self, node: &Node, fmt: &mut dyn markdown_it::Renderer) { + // simply pass-through to children + fmt.contents(&node.children); + } +} + +// This is an extension for the inline subparser. +struct InlineFootnoteScanner; + +impl InlineRule for InlineFootnoteScanner { + const MARKER: char = '^'; + + fn check(state: &mut InlineState<'_, '_>) -> Option { + let mut chars = state.src[state.pos..state.pos_max].chars(); + + // check line starts with the correct syntax + let Some('^') = chars.next() else { + return None; + }; + let Some('[') = chars.next() else { + return None; + }; + + let content_start = state.pos + 2; + + match parse_footnote(state, content_start) { + Some(content_end) => Some(content_end + 1 - state.pos), + None => None, + } + } + + fn run(state: &mut InlineState<'_, '_>) -> Option<(Node, usize)> { + let mut chars = state.src[state.pos..state.pos_max].chars(); + + // check line starts with the correct syntax + let Some('^') = chars.next() else { + return None; + }; + let Some('[') = chars.next() else { + return None; + }; + + let content_start = state.pos + 2; + + match parse_footnote(state, content_start) { + Some(content_end) => { + let foot_map = state.root_ext.get_or_insert_default::(); + let (def_id, ref_id) = foot_map.add_inline_def(); + + // create node and set it as current + let current_node = std::mem::replace( + &mut state.node, + Node::new(FootnoteDefinition { + label: None, + def_id: Some(def_id), + inline: true, + }), + ); + + // perform nested parsing + let start = state.pos; + let max = state.pos_max; + state.pos = content_start; + state.pos_max = content_end; + state.md.inline.tokenize(state); + state.pos = start; + state.pos_max = max; + + // restore current node + let def_node = std::mem::replace(&mut state.node, current_node); + + let ref_node = Node::new(crate::references::FootnoteReference { + label: None, + ref_id, + def_id, + }); + + // wrap the footnote definition and reference in an outer node to return + let mut outer_node = Node::new(InlineFootnote); + outer_node.children = vec![def_node, ref_node]; + + Some((outer_node, content_end + 1 - state.pos)) + } + None => None, + } + } +} + +// returns the end position of the footnote +// this function assumes that first character ("[") already matches; +fn parse_footnote(state: &mut InlineState<'_, '_>, start: usize) -> Option { + let old_pos = state.pos; + let mut label_end = None; + state.pos = start + 1; + let mut found = false; + while let Some(ch) = state.src[state.pos..state.pos_max].chars().next() { + if ch == ']' { + found = true; + break; + } + state.md.inline.skip_token(state); + } + + if found { + label_end = Some(state.pos); + } + + // restore old state + state.pos = old_pos; + + label_end +} diff --git a/crates/lib/md-footnote/src/lib.rs b/crates/lib/md-footnote/src/lib.rs new file mode 100644 index 0000000..5a89c14 --- /dev/null +++ b/crates/lib/md-footnote/src/lib.rs @@ -0,0 +1,89 @@ +//! A [markdown_it] plugin for parsing footnotes +//! +//! ``` +//! let parser = &mut markdown_it::MarkdownIt::new(); +//! md_footnote::add(parser); +//! let node = parser.parse("[^note]\n\n[^note]: A footnote\n"); +//! ``` +use std::collections::HashMap; + +use markdown_it::{MarkdownIt, parser::extset::RootExt}; + +pub mod back_refs; +pub mod collect; +pub mod definitions; +pub mod inline; +pub mod references; + +// Silence lints +#[cfg(test)] +use md_dev as _; + +#[cfg(test)] +use testing as _; + +/// Add the full footnote plugin to the parser +pub fn add(md: &mut MarkdownIt) { + definitions::add(md); + references::add(md); + inline::add(md); + collect::add(md); + back_refs::add(md); +} + +#[derive(Debug, Default)] +/// The set of parsed footnote definition labels, +/// stored in the root node. +pub struct FootnoteMap { + def_counter: usize, + ref_counter: usize, + label_to_def: HashMap, + def_to_refs: HashMap>, +} +impl RootExt for FootnoteMap {} +impl FootnoteMap { + /// Create an ID for the definition, + /// or return None if a definition already exists for the label + pub fn add_def(&mut self, label: &str) -> Option { + if self.label_to_def.contains_key(label) { + return None; + } + self.def_counter += 1; + self.label_to_def + .insert(String::from(label), self.def_counter); + Some(self.def_counter) + } + /// Create an ID for the reference and return (def_id, ref_id), + /// or return None if no definition exists for the label + pub fn add_ref(&mut self, label: &str) -> Option<(usize, usize)> { + match self.label_to_def.get(label) { + Some(def_id) => { + self.ref_counter += 1; + // self.def_to_refs.get_mut(&def_id).unwrap().push(self.ref_counter); + match self.def_to_refs.get_mut(def_id) { + Some(refs) => refs.push(self.ref_counter), + None => { + self.def_to_refs.insert(*def_id, vec![self.ref_counter]); + } + } + Some((*def_id, self.ref_counter)) + } + None => None, + } + } + /// Add an inline definition and return (def_id, ref_id) + pub fn add_inline_def(&mut self) -> (usize, usize) { + self.def_counter += 1; + self.ref_counter += 1; + self.def_to_refs + .insert(self.def_counter, vec![self.ref_counter]); + (self.def_counter, self.ref_counter) + } + /// return the IDs of all references to the given definition ID + pub fn referenced_by(&self, def_id: usize) -> Vec { + match self.def_to_refs.get(&def_id) { + Some(ids) => ids.clone(), + None => Vec::new(), + } + } +} diff --git a/crates/lib/md-footnote/src/references.rs b/crates/lib/md-footnote/src/references.rs new file mode 100644 index 0000000..b03a214 --- /dev/null +++ b/crates/lib/md-footnote/src/references.rs @@ -0,0 +1,108 @@ +//! Plugin to parse footnote references +//! +//! ```rust +//! let parser = &mut markdown_it::MarkdownIt::new(); +//! markdown_it::plugins::cmark::add(parser); +//! md_footnote::references::add(parser); +//! md_footnote::definitions::add(parser); +//! let root = parser.parse("[^label]\n\n[^label]: This is a footnote"); +//! let mut names = vec![]; +//! root.walk(|node,_| { names.push(node.name()); }); +//! assert_eq!(names, vec![ +//! "markdown_it::parser::core::root::Root", +//! "markdown_it::plugins::cmark::block::paragraph::Paragraph", +//! "md_footnote::references::FootnoteReference", +//! "md_footnote::definitions::FootnoteDefinition", +//! "markdown_it::plugins::cmark::block::paragraph::Paragraph", +//! "markdown_it::parser::inline::builtin::skip_text::Text" +//! ]); +//! ``` +use markdown_it::parser::inline::{InlineRule, InlineState}; +use markdown_it::{MarkdownIt, Node, NodeValue, Renderer}; + +use crate::FootnoteMap; + +/// Add the footnote reference parsing to the markdown parser +pub fn add(md: &mut MarkdownIt) { + // insert this rule into inline subparser + md.inline.add_rule::(); +} + +#[derive(Debug)] +/// AST node for footnote reference +pub struct FootnoteReference { + pub label: Option, + pub ref_id: usize, + pub def_id: usize, +} + +impl NodeValue for FootnoteReference { + fn render(&self, node: &Node, fmt: &mut dyn Renderer) { + let mut attrs = node.attrs.clone(); + attrs.push(("class", "footnote-ref".into())); + + fmt.open("sup", &attrs); + fmt.open( + "a", + &[ + ("href", format!("#fn{}", self.def_id)), + ("id", format!("fnref{}", self.ref_id)), + ], + ); + fmt.text(&format!("[{}]", self.def_id)); + fmt.close("a"); + fmt.close("sup"); + } +} + +// This is an extension for the inline subparser. +struct FootnoteReferenceScanner; + +impl InlineRule for FootnoteReferenceScanner { + const MARKER: char = '['; + + fn run(state: &mut InlineState<'_, '_>) -> Option<(Node, usize)> { + let mut chars = state.src[state.pos..state.pos_max].chars(); + + // check line starts with the correct syntax + let Some('[') = chars.next() else { + return None; + }; + let Some('^') = chars.next() else { + return None; + }; + + // gather the label + let mut label = String::new(); + // The labels in footnote references may not contain spaces, tabs, or newlines. + // Backslash escapes form part of the label and do not escape anything + loop { + match chars.next() { + None => return None, + Some(']') => { + break; + } + Some(' ') => return None, + Some(c) => label.push(c), + } + } + if label.is_empty() { + return None; + } + + let definitions = state.root_ext.get_or_insert_default::(); + let (def_id, ref_id) = definitions.add_ref(&label)?; + + let length = label.len() + 3; // 3 for '[^' and ']' + + // return new node and length of this structure + Some(( + Node::new(FootnoteReference { + label: Some(label), + ref_id, + def_id, + }), + length, + )) + } +} diff --git a/crates/lib/md-footnote/tests/fixtures.rs b/crates/lib/md-footnote/tests/fixtures.rs new file mode 100644 index 0000000..88c26b1 --- /dev/null +++ b/crates/lib/md-footnote/tests/fixtures.rs @@ -0,0 +1,15 @@ +use std::path::PathBuf; +use testing::fixture; + +#[fixture("tests/fixtures/[!_]*.md")] +fn test_html(file: PathBuf) { + let f = md_dev::read_fixture_file(file); + + let parser = &mut markdown_it::MarkdownIt::new(); + markdown_it::plugins::sourcepos::add(parser); + markdown_it::plugins::cmark::add(parser); + md_footnote::add(parser); + let actual = parser.parse(&f.input).render(); + + md_dev::assert_no_diff(f, &actual); +} diff --git a/crates/lib/md-footnote/tests/fixtures/0.md b/crates/lib/md-footnote/tests/fixtures/0.md new file mode 100644 index 0000000..629ec61 --- /dev/null +++ b/crates/lib/md-footnote/tests/fixtures/0.md @@ -0,0 +1,31 @@ +Basic test + +...... + +[^a] +[^a] + +[^a]: Multi +line + + Multi-paragraph + +[^a]: duplicate + +normal paragraph + +...... + +

[1] +[1]

+

normal paragraph

+
+
+
    +
  1. +

    Multi +line

    +

    Multi-paragraph ↩︎ ↩︎

    +
  2. +
+
diff --git a/crates/lib/md-footnote/tests/fixtures/1.md b/crates/lib/md-footnote/tests/fixtures/1.md new file mode 100644 index 0000000..2f6de52 --- /dev/null +++ b/crates/lib/md-footnote/tests/fixtures/1.md @@ -0,0 +1,45 @@ +Pandoc example + +...... + +Here is a footnote reference,[^1] and another.[^longnote] + +[^1]: Here is the footnote. + +[^longnote]: Here's one with multiple blocks. + + Subsequent paragraphs are indented to show that they +belong to the previous footnote. + + { some.code } + + The whole paragraph can be indented, or just the first + line. In this way, multi-paragraph footnotes work like + multi-paragraph list items. + +This paragraph won't be part of the note, because it +isn't indented. + +...... + +

Here is a footnote reference,[1] and another.[2]

+

This paragraph won't be part of the note, because it +isn't indented.

+
+
+
    +
  1. +

    Here is the footnote. ↩︎

    +
  2. +
  3. +

    Here's one with multiple blocks.

    +

    Subsequent paragraphs are indented to show that they +belong to the previous footnote.

    +
    { some.code }
    +
    +

    The whole paragraph can be indented, or just the first +line. In this way, multi-paragraph footnotes work like +multi-paragraph list items. ↩︎

    +
  4. +
+
diff --git a/crates/lib/md-footnote/tests/fixtures/10.md b/crates/lib/md-footnote/tests/fixtures/10.md new file mode 100644 index 0000000..63d22bf --- /dev/null +++ b/crates/lib/md-footnote/tests/fixtures/10.md @@ -0,0 +1,19 @@ +Newline after footnote identifier + +...... + +[^a] + +[^a]: +b + +...... + +

[1]

+

b

+
+
+
    +
  1. ↩︎
  2. +
+
diff --git a/crates/lib/md-footnote/tests/fixtures/2.md b/crates/lib/md-footnote/tests/fixtures/2.md new file mode 100644 index 0000000..824fcb5 --- /dev/null +++ b/crates/lib/md-footnote/tests/fixtures/2.md @@ -0,0 +1,27 @@ +They could terminate each other + +...... + +[^1][^2][^3] + +[^1]: foo +[^2]: bar +[^3]: baz + +...... + +

[1][2][3]

+
+
+
    +
  1. +

    foo ↩︎

    +
  2. +
  3. +

    bar ↩︎

    +
  4. +
  5. +

    baz ↩︎

    +
  6. +
+
diff --git a/crates/lib/md-footnote/tests/fixtures/3.md b/crates/lib/md-footnote/tests/fixtures/3.md new file mode 100644 index 0000000..caee05b --- /dev/null +++ b/crates/lib/md-footnote/tests/fixtures/3.md @@ -0,0 +1,23 @@ +They could be inside blockquotes, and are lazy + +...... + +[^foo] + +> [^foo]: bar +baz + +...... + +

[1]

+
+
+
+
+
    +
  1. +

    bar +baz ↩︎

    +
  2. +
+
diff --git a/crates/lib/md-footnote/tests/fixtures/4.md b/crates/lib/md-footnote/tests/fixtures/4.md new file mode 100644 index 0000000..1573c00 --- /dev/null +++ b/crates/lib/md-footnote/tests/fixtures/4.md @@ -0,0 +1,14 @@ +Their labels could not contain spaces or newlines + +...... + +[^ foo]: bar baz + +[^foo +]: bar baz + +...... + +

[^ foo]: bar baz

+

[^foo +]: bar baz

diff --git a/crates/lib/md-footnote/tests/fixtures/5.md b/crates/lib/md-footnote/tests/fixtures/5.md new file mode 100644 index 0000000..6c58dcd --- /dev/null +++ b/crates/lib/md-footnote/tests/fixtures/5.md @@ -0,0 +1,19 @@ +Duplicate footnotes: + +...... + +[^xxxxx] [^xxxxx] + +[^xxxxx]: foo + +...... + +

[1] [1]

+
+
+
    +
  1. +

    foo ↩︎ ↩︎

    +
  2. +
+
diff --git a/crates/lib/md-footnote/tests/fixtures/6.md b/crates/lib/md-footnote/tests/fixtures/6.md new file mode 100644 index 0000000..a5c78db --- /dev/null +++ b/crates/lib/md-footnote/tests/fixtures/6.md @@ -0,0 +1,28 @@ +Indents + + +...... + +[^xxxxx] [^yyyyy] + +[^xxxxx]: foo + --- + +[^yyyyy]: foo + --- + +...... + +

[1] [2]

+
+
+
+
    +
  1. +

    foo

    + ↩︎
  2. +
  3. +

    foo ↩︎

    +
  4. +
+
diff --git a/crates/lib/md-footnote/tests/fixtures/8.md b/crates/lib/md-footnote/tests/fixtures/8.md new file mode 100644 index 0000000..2688187 --- /dev/null +++ b/crates/lib/md-footnote/tests/fixtures/8.md @@ -0,0 +1,19 @@ +Indents for the first line (tabs) + +...... + +[^xxxxx] + +[^xxxxx]: foo + +...... + +

[1]

+
+
+
    +
  1. +

    foo ↩︎

    +
  2. +
+
diff --git a/crates/lib/md-footnote/tests/fixtures/9.md b/crates/lib/md-footnote/tests/fixtures/9.md new file mode 100644 index 0000000..ef8ce62 --- /dev/null +++ b/crates/lib/md-footnote/tests/fixtures/9.md @@ -0,0 +1,37 @@ +Nested blocks + +...... + +[^a] + +[^a]: abc + + def +hij + + - list + + > block + +terminates here + +...... + +

[1]

+

terminates here

+
+
+
    +
  1. +

    abc

    +

    def +hij

    +
      +
    • list
    • +
    +
    +

    block

    +
    + ↩︎
  2. +
+
diff --git a/crates/lib/md-footnote/tests/fixtures/_7.md b/crates/lib/md-footnote/tests/fixtures/_7.md new file mode 100644 index 0000000..687ab55 --- /dev/null +++ b/crates/lib/md-footnote/tests/fixtures/_7.md @@ -0,0 +1,24 @@ +Indents for the first line +............. + +[^xxxxx] [^yyyyy] + +[^xxxxx]: foo + +[^yyyyy]: foo + +............. + +

[1] [2]

+
+
+
    +
  1. +

    foo ↩︎

    +
  2. +
  3. +
    foo
    +
    + ↩︎
  4. +
+
diff --git a/crates/lib/md-footnote/tests/fixtures/inline-1.md b/crates/lib/md-footnote/tests/fixtures/inline-1.md new file mode 100644 index 0000000..cabe0c7 --- /dev/null +++ b/crates/lib/md-footnote/tests/fixtures/inline-1.md @@ -0,0 +1,21 @@ +We support inline notes too (pandoc example) + +...... + +Here is an inline note.^[Inlines notes are easier to write, since +you don't have to pick an identifier and move down to type the +note.] + +...... + +

Here is an inline note.[1]

+
+
+
    +
  1. +

    Inlines notes are easier to write, since +you don't have to pick an identifier and move down to type the +note. ↩︎

    +
  2. +
+
diff --git a/crates/lib/md-footnote/tests/fixtures/inline-2.md b/crates/lib/md-footnote/tests/fixtures/inline-2.md new file mode 100644 index 0000000..262485b --- /dev/null +++ b/crates/lib/md-footnote/tests/fixtures/inline-2.md @@ -0,0 +1,17 @@ +Inline footnotes can have arbitrary markup + +...... + +foo^[ *bar* ] + +...... + +

foo[1]

+
+
+
    +
  1. +

    bar ↩︎

    +
  2. +
+
diff --git a/crates/lib/md-footnote/tests/fixtures/inline-3.md b/crates/lib/md-footnote/tests/fixtures/inline-3.md new file mode 100644 index 0000000..052fbfb --- /dev/null +++ b/crates/lib/md-footnote/tests/fixtures/inline-3.md @@ -0,0 +1,19 @@ +Should allow links in inline footnotes + +...... + +Example^[this is another example [a]] + +[a]: https://github.com + +...... + +

Example[1]

+
+
+
    +
  1. +

    this is another example a ↩︎

    +
  2. +
+
diff --git a/crates/lib/md-footnote/tests/fixtures/inline-4.md b/crates/lib/md-footnote/tests/fixtures/inline-4.md new file mode 100644 index 0000000..5d03d91 --- /dev/null +++ b/crates/lib/md-footnote/tests/fixtures/inline-4.md @@ -0,0 +1,19 @@ +nested inline footnotes + +...... + +[Example^[this is another example [a]]][a] + +[a]: https://github.com + +...... + +

Example[1]

+
+
+
    +
  1. +

    this is another example a ↩︎

    +
  2. +
+