Compare commits
3 Commits
4d8093c4a3
...
a3ff195de9
| Author | SHA1 | Date | |
|---|---|---|---|
| a3ff195de9 | |||
| d508a0d031 | |||
| dc4260e147 |
518
Cargo.lock
generated
518
Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -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
|
||||
#
|
||||
|
||||
125
bacon.toml
Normal file
125
bacon.toml
Normal file
@@ -0,0 +1,125 @@
|
||||
# This is a configuration file for the bacon tool
|
||||
#
|
||||
# Complete help on configuration: https://dystroy.org/bacon/config/
|
||||
#
|
||||
# You may check the current default at
|
||||
# https://github.com/Canop/bacon/blob/main/defaults/default-bacon.toml
|
||||
|
||||
default_job = "run"
|
||||
env.CARGO_TERM_COLOR = "always"
|
||||
|
||||
[jobs.check]
|
||||
command = ["cargo", "check"]
|
||||
need_stdout = false
|
||||
|
||||
[jobs.check-all]
|
||||
command = ["cargo", "check", "--all-targets"]
|
||||
need_stdout = false
|
||||
|
||||
# Run clippy on the default target
|
||||
[jobs.clippy]
|
||||
command = ["cargo", "clippy"]
|
||||
need_stdout = false
|
||||
|
||||
# Run clippy on all targets
|
||||
# To disable some lints, you may change the job this way:
|
||||
# [jobs.clippy-all]
|
||||
# command = [
|
||||
# "cargo", "clippy",
|
||||
# "--all-targets",
|
||||
# "--",
|
||||
# "-A", "clippy::bool_to_int_with_if",
|
||||
# "-A", "clippy::collapsible_if",
|
||||
# "-A", "clippy::derive_partial_eq_without_eq",
|
||||
# ]
|
||||
# need_stdout = false
|
||||
[jobs.clippy-all]
|
||||
command = ["cargo", "clippy", "--all-targets"]
|
||||
need_stdout = false
|
||||
|
||||
# Run clippy in pedantic mode
|
||||
# The 'dismiss' feature may come handy
|
||||
[jobs.pedantic]
|
||||
command = [
|
||||
"cargo", "clippy",
|
||||
"--",
|
||||
"-W", "clippy::pedantic",
|
||||
]
|
||||
need_stdout = false
|
||||
|
||||
# This job lets you run
|
||||
# - all tests: bacon test
|
||||
# - a specific test: bacon test -- config::test_default_files
|
||||
# - the tests of a package: bacon test -- -- -p config
|
||||
[jobs.test]
|
||||
command = ["cargo", "test"]
|
||||
need_stdout = true
|
||||
|
||||
[jobs.nextest]
|
||||
command = [
|
||||
"cargo", "nextest", "run",
|
||||
"--hide-progress-bar", "--failure-output", "final"
|
||||
]
|
||||
need_stdout = true
|
||||
analyzer = "nextest"
|
||||
|
||||
[jobs.doc]
|
||||
command = ["cargo", "doc", "--no-deps"]
|
||||
need_stdout = false
|
||||
|
||||
# If the doc compiles, then it opens in your browser and bacon switches
|
||||
# to the previous job
|
||||
[jobs.doc-open]
|
||||
command = ["cargo", "doc", "--no-deps", "--open"]
|
||||
need_stdout = false
|
||||
on_success = "back" # so that we don't open the browser at each change
|
||||
|
||||
# You can run your application and have the result displayed in bacon,
|
||||
# if it makes sense for this crate.
|
||||
[jobs.run]
|
||||
command = [
|
||||
"cargo", "run", "serve", "0.0.0.0:3030",
|
||||
]
|
||||
need_stdout = true
|
||||
allow_warnings = true
|
||||
background = true
|
||||
on_change_strategy = "kill_then_restart"
|
||||
|
||||
# Run your long-running application (eg server) and have the result displayed in bacon.
|
||||
# For programs that never stop (eg a server), `background` is set to false
|
||||
# to have the cargo run output immediately displayed instead of waiting for
|
||||
# program's end.
|
||||
# 'on_change_strategy' is set to `kill_then_restart` to have your program restart
|
||||
# on every change (an alternative would be to use the 'F5' key manually in bacon).
|
||||
# If you often use this job, it makes sense to override the 'r' key by adding
|
||||
# a binding `r = job:run-long` at the end of this file .
|
||||
# A custom kill command such as the one suggested below is frequently needed to kill
|
||||
# long running programs (uncomment it if you need it)
|
||||
[jobs.run-long]
|
||||
command = [
|
||||
"cargo", "run",
|
||||
# put launch parameters for your program behind a `--` separator
|
||||
]
|
||||
need_stdout = true
|
||||
allow_warnings = true
|
||||
background = false
|
||||
on_change_strategy = "kill_then_restart"
|
||||
# kill = ["pkill", "-TERM", "-P"]
|
||||
|
||||
# This parameterized job runs the example of your choice, as soon
|
||||
# as the code compiles.
|
||||
# Call it as
|
||||
# bacon ex -- my-example
|
||||
[jobs.ex]
|
||||
command = ["cargo", "run", "--example"]
|
||||
need_stdout = true
|
||||
allow_warnings = true
|
||||
|
||||
# You may define here keybindings that would be specific to
|
||||
# a project, for example a shortcut to launch a specific job.
|
||||
# Shortcuts to internal functions (scrolling, toggling, etc.)
|
||||
# should go in your personal global prefs.toml file instead.
|
||||
[keybindings]
|
||||
# alt-m = "job:my-job"
|
||||
c = "job:clippy-all" # comment this to have 'c' run clippy on only the default target
|
||||
p = "job:pedantic"
|
||||
15
crates/lib/md-dev/Cargo.toml
Normal file
15
crates/lib/md-dev/Cargo.toml
Normal file
@@ -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 }
|
||||
109
crates/lib/md-dev/src/lib.rs
Normal file
109
crates/lib/md-dev/src/lib.rs
Normal file
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
21
crates/lib/md-footnote/Cargo.toml
Normal file
21
crates/lib/md-footnote/Cargo.toml
Normal file
@@ -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 }
|
||||
59
crates/lib/md-footnote/README.md
Normal file
59
crates/lib/md-footnote/README.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# markdown-it-footnote.rs
|
||||
|
||||
[<img alt="crates.io" src="https://img.shields.io/crates/v/markdown-it-footnote.svg?style=for-the-badge&color=fc8d62&logo=rust" height="20">](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)
|
||||
107
crates/lib/md-footnote/src/back_refs.rs
Normal file
107
crates/lib/md-footnote/src/back_refs.rs
Normal file
@@ -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::<FootnoteBackrefRule>();
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct FootnoteRefAnchor {
|
||||
pub ref_ids: Vec<usize>,
|
||||
}
|
||||
impl NodeValue for FootnoteRefAnchor {
|
||||
fn render(&self, _: &Node, fmt: &mut dyn markdown_it::Renderer) {
|
||||
for ref_id in self.ref_ids.iter() {
|
||||
fmt.text_raw(" ");
|
||||
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_raw("back \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::<Root>().unwrap();
|
||||
|
||||
let root_ext = std::mem::take(&mut data.ext);
|
||||
let map = match root_ext.get::<FootnoteMap>() {
|
||||
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::<FootnoteDefinition>() {
|
||||
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::<Paragraph>() {
|
||||
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::<Root>().unwrap();
|
||||
data.ext = root_ext;
|
||||
}
|
||||
}
|
||||
140
crates/lib/md-footnote/src/collect.rs
Normal file
140
crates/lib/md-footnote/src/collect.rs
Normal file
@@ -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::<FootnoteCollectRule>();
|
||||
}
|
||||
|
||||
#[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::<Root>().unwrap();
|
||||
|
||||
let root_ext = std::mem::take(&mut data.ext);
|
||||
let map = match root_ext.get::<FootnoteMap>() {
|
||||
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::<FootnoteDefinition>())
|
||||
// .collect(),
|
||||
// );
|
||||
|
||||
for child in node.children.iter_mut() {
|
||||
if child.is::<FootnoteDefinition>() {
|
||||
let mut extracted = std::mem::replace(child, Node::new(PlaceholderNode));
|
||||
match extracted.cast::<FootnoteDefinition>() {
|
||||
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::<PlaceholderNode>());
|
||||
});
|
||||
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::<Root>().unwrap();
|
||||
|
||||
data.ext = root_ext;
|
||||
}
|
||||
}
|
||||
179
crates/lib/md-footnote/src/definitions.rs
Normal file
179
crates/lib/md-footnote/src/definitions.rs
Normal file
@@ -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::<FootnoteDefinitionScanner>()
|
||||
.before::<ReferenceScanner>();
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
/// AST node for footnote definition
|
||||
pub struct FootnoteDefinition {
|
||||
pub label: Option<String>,
|
||||
pub def_id: Option<usize>,
|
||||
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::<FootnoteDefinition>() && 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::<FootnoteMap>();
|
||||
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::<FootnoteDefinition>());
|
||||
|
||||
// let text = node.render();
|
||||
// assert_eq!(text, "hallo\n")
|
||||
}
|
||||
}
|
||||
147
crates/lib/md-footnote/src/inline.rs
Normal file
147
crates/lib/md-footnote/src/inline.rs
Normal file
@@ -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::<InlineFootnoteScanner>();
|
||||
}
|
||||
|
||||
#[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<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) => 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::<FootnoteMap>();
|
||||
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<usize> {
|
||||
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
|
||||
}
|
||||
89
crates/lib/md-footnote/src/lib.rs
Normal file
89
crates/lib/md-footnote/src/lib.rs
Normal file
@@ -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<String, usize>,
|
||||
def_to_refs: HashMap<usize, Vec<usize>>,
|
||||
}
|
||||
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<usize> {
|
||||
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<usize> {
|
||||
match self.def_to_refs.get(&def_id) {
|
||||
Some(ids) => ids.clone(),
|
||||
None => Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
108
crates/lib/md-footnote/src/references.rs
Normal file
108
crates/lib/md-footnote/src/references.rs
Normal file
@@ -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::<FootnoteReferenceScanner>();
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
/// AST node for footnote reference
|
||||
pub struct FootnoteReference {
|
||||
pub label: Option<String>,
|
||||
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::<FootnoteMap>();
|
||||
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,
|
||||
))
|
||||
}
|
||||
}
|
||||
19
crates/lib/md-footnote/tests/fixtures.rs
Normal file
19
crates/lib/md-footnote/tests/fixtures.rs
Normal file
@@ -0,0 +1,19 @@
|
||||
#![expect(unused_imports)]
|
||||
#![expect(unused_crate_dependencies)]
|
||||
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);
|
||||
}
|
||||
*/
|
||||
31
crates/lib/md-footnote/tests/fixtures/0.md
vendored
Normal file
31
crates/lib/md-footnote/tests/fixtures/0.md
vendored
Normal file
@@ -0,0 +1,31 @@
|
||||
Basic test
|
||||
|
||||
......
|
||||
|
||||
[^a]
|
||||
[^a]
|
||||
|
||||
[^a]: Multi
|
||||
line
|
||||
|
||||
Multi-paragraph
|
||||
|
||||
[^a]: duplicate
|
||||
|
||||
normal paragraph
|
||||
|
||||
......
|
||||
|
||||
<p data-sourcepos="1:1-2:4"><sup data-sourcepos="1:1-1:4" class="footnote-ref"><a href="#fn1" id="fnref1">[1]</a></sup>
|
||||
<sup data-sourcepos="2:1-2:4" class="footnote-ref"><a href="#fn1" id="fnref2">[1]</a></sup></p>
|
||||
<p data-sourcepos="11:1-11:16">normal paragraph</p>
|
||||
<hr class="footnotes-sep">
|
||||
<section class="footnotes">
|
||||
<ol class="footnotes-list">
|
||||
<li data-sourcepos="4:1-8:0" id="fn1" class="footnote-item">
|
||||
<p data-sourcepos="4:7-5:4">Multi
|
||||
line</p>
|
||||
<p data-sourcepos="7:5-7:19">Multi-paragraph <a href="#fnref1" class="footnote-backref">↩︎</a> <a href="#fnref2" class="footnote-backref">↩︎</a></p>
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
45
crates/lib/md-footnote/tests/fixtures/1.md
vendored
Normal file
45
crates/lib/md-footnote/tests/fixtures/1.md
vendored
Normal file
@@ -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.
|
||||
|
||||
......
|
||||
|
||||
<p data-sourcepos="1:1-1:57">Here is a footnote reference,<sup data-sourcepos="1:30-1:33" class="footnote-ref"><a href="#fn1" id="fnref1">[1]</a></sup> and another.<sup data-sourcepos="1:47-1:57" class="footnote-ref"><a href="#fn2" id="fnref2">[2]</a></sup></p>
|
||||
<p data-sourcepos="16:1-17:15">This paragraph won't be part of the note, because it
|
||||
isn't indented.</p>
|
||||
<hr class="footnotes-sep">
|
||||
<section class="footnotes">
|
||||
<ol class="footnotes-list">
|
||||
<li data-sourcepos="3:1-4:0" id="fn1" class="footnote-item">
|
||||
<p data-sourcepos="3:7-3:27">Here is the footnote. <a href="#fnref1" class="footnote-backref">↩︎</a></p>
|
||||
</li>
|
||||
<li data-sourcepos="5:1-15:0" id="fn2" class="footnote-item">
|
||||
<p data-sourcepos="5:14-5:45">Here's one with multiple blocks.</p>
|
||||
<p data-sourcepos="7:5-8:32">Subsequent paragraphs are indented to show that they
|
||||
belong to the previous footnote.</p>
|
||||
<pre><code data-sourcepos="10:9-10:21">{ some.code }
|
||||
</code></pre>
|
||||
<p data-sourcepos="12:5-14:31">The whole paragraph can be indented, or just the first
|
||||
line. In this way, multi-paragraph footnotes work like
|
||||
multi-paragraph list items. <a href="#fnref2" class="footnote-backref">↩︎</a></p>
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
19
crates/lib/md-footnote/tests/fixtures/10.md
vendored
Normal file
19
crates/lib/md-footnote/tests/fixtures/10.md
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
Newline after footnote identifier
|
||||
|
||||
......
|
||||
|
||||
[^a]
|
||||
|
||||
[^a]:
|
||||
b
|
||||
|
||||
......
|
||||
|
||||
<p data-sourcepos="1:1-1:4"><sup data-sourcepos="1:1-1:4" class="footnote-ref"><a href="#fn1" id="fnref1">[1]</a></sup></p>
|
||||
<p data-sourcepos="4:1-4:1">b</p>
|
||||
<hr class="footnotes-sep">
|
||||
<section class="footnotes">
|
||||
<ol class="footnotes-list">
|
||||
<li data-sourcepos="3:1-3:5" id="fn1" class="footnote-item"> <a href="#fnref1" class="footnote-backref">↩︎</a></li>
|
||||
</ol>
|
||||
</section>
|
||||
27
crates/lib/md-footnote/tests/fixtures/2.md
vendored
Normal file
27
crates/lib/md-footnote/tests/fixtures/2.md
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
They could terminate each other
|
||||
|
||||
......
|
||||
|
||||
[^1][^2][^3]
|
||||
|
||||
[^1]: foo
|
||||
[^2]: bar
|
||||
[^3]: baz
|
||||
|
||||
......
|
||||
|
||||
<p data-sourcepos="1:1-1:12"><sup data-sourcepos="1:1-1:4" class="footnote-ref"><a href="#fn1" id="fnref1">[1]</a></sup><sup data-sourcepos="1:5-1:8" class="footnote-ref"><a href="#fn2" id="fnref2">[2]</a></sup><sup data-sourcepos="1:9-1:12" class="footnote-ref"><a href="#fn3" id="fnref3">[3]</a></sup></p>
|
||||
<hr class="footnotes-sep">
|
||||
<section class="footnotes">
|
||||
<ol class="footnotes-list">
|
||||
<li data-sourcepos="3:1-3:9" id="fn1" class="footnote-item">
|
||||
<p data-sourcepos="3:7-3:9">foo <a href="#fnref1" class="footnote-backref">↩︎</a></p>
|
||||
</li>
|
||||
<li data-sourcepos="4:1-4:9" id="fn2" class="footnote-item">
|
||||
<p data-sourcepos="4:7-4:9">bar <a href="#fnref2" class="footnote-backref">↩︎</a></p>
|
||||
</li>
|
||||
<li data-sourcepos="5:1-5:9" id="fn3" class="footnote-item">
|
||||
<p data-sourcepos="5:7-5:9">baz <a href="#fnref3" class="footnote-backref">↩︎</a></p>
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
23
crates/lib/md-footnote/tests/fixtures/3.md
vendored
Normal file
23
crates/lib/md-footnote/tests/fixtures/3.md
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
They could be inside blockquotes, and are lazy
|
||||
|
||||
......
|
||||
|
||||
[^foo]
|
||||
|
||||
> [^foo]: bar
|
||||
baz
|
||||
|
||||
......
|
||||
|
||||
<p data-sourcepos="1:1-1:6"><sup data-sourcepos="1:1-1:6" class="footnote-ref"><a href="#fn1" id="fnref1">[1]</a></sup></p>
|
||||
<blockquote data-sourcepos="3:1-4:3">
|
||||
</blockquote>
|
||||
<hr class="footnotes-sep">
|
||||
<section class="footnotes">
|
||||
<ol class="footnotes-list">
|
||||
<li data-sourcepos="3:3-4:3" id="fn1" class="footnote-item">
|
||||
<p data-sourcepos="3:11-4:3">bar
|
||||
baz <a href="#fnref1" class="footnote-backref">↩︎</a></p>
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
14
crates/lib/md-footnote/tests/fixtures/4.md
vendored
Normal file
14
crates/lib/md-footnote/tests/fixtures/4.md
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
Their labels could not contain spaces or newlines
|
||||
|
||||
......
|
||||
|
||||
[^ foo]: bar baz
|
||||
|
||||
[^foo
|
||||
]: bar baz
|
||||
|
||||
......
|
||||
|
||||
<p data-sourcepos="1:1-1:16">[^ foo]: bar baz</p>
|
||||
<p data-sourcepos="3:1-4:10">[^foo
|
||||
]: bar baz</p>
|
||||
19
crates/lib/md-footnote/tests/fixtures/5.md
vendored
Normal file
19
crates/lib/md-footnote/tests/fixtures/5.md
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
Duplicate footnotes:
|
||||
|
||||
......
|
||||
|
||||
[^xxxxx] [^xxxxx]
|
||||
|
||||
[^xxxxx]: foo
|
||||
|
||||
......
|
||||
|
||||
<p data-sourcepos="1:1-1:17"><sup data-sourcepos="1:1-1:8" class="footnote-ref"><a href="#fn1" id="fnref1">[1]</a></sup> <sup data-sourcepos="1:10-1:17" class="footnote-ref"><a href="#fn1" id="fnref2">[1]</a></sup></p>
|
||||
<hr class="footnotes-sep">
|
||||
<section class="footnotes">
|
||||
<ol class="footnotes-list">
|
||||
<li data-sourcepos="3:1-3:13" id="fn1" class="footnote-item">
|
||||
<p data-sourcepos="3:11-3:13">foo <a href="#fnref1" class="footnote-backref">↩︎</a> <a href="#fnref2" class="footnote-backref">↩︎</a></p>
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
28
crates/lib/md-footnote/tests/fixtures/6.md
vendored
Normal file
28
crates/lib/md-footnote/tests/fixtures/6.md
vendored
Normal file
@@ -0,0 +1,28 @@
|
||||
Indents
|
||||
|
||||
|
||||
......
|
||||
|
||||
[^xxxxx] [^yyyyy]
|
||||
|
||||
[^xxxxx]: foo
|
||||
---
|
||||
|
||||
[^yyyyy]: foo
|
||||
---
|
||||
|
||||
......
|
||||
|
||||
<p data-sourcepos="1:1-1:17"><sup data-sourcepos="1:1-1:8" class="footnote-ref"><a href="#fn1" id="fnref1">[1]</a></sup> <sup data-sourcepos="1:10-1:17" class="footnote-ref"><a href="#fn2" id="fnref2">[2]</a></sup></p>
|
||||
<hr data-sourcepos="7:4-7:6">
|
||||
<hr class="footnotes-sep">
|
||||
<section class="footnotes">
|
||||
<ol class="footnotes-list">
|
||||
<li data-sourcepos="3:1-5:0" id="fn1" class="footnote-item">
|
||||
<h2 data-sourcepos="3:11-4:7">foo</h2>
|
||||
<a href="#fnref1" class="footnote-backref">↩︎</a></li>
|
||||
<li data-sourcepos="6:1-6:13" id="fn2" class="footnote-item">
|
||||
<p data-sourcepos="6:11-6:13">foo <a href="#fnref2" class="footnote-backref">↩︎</a></p>
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
19
crates/lib/md-footnote/tests/fixtures/8.md
vendored
Normal file
19
crates/lib/md-footnote/tests/fixtures/8.md
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
Indents for the first line (tabs)
|
||||
|
||||
......
|
||||
|
||||
[^xxxxx]
|
||||
|
||||
[^xxxxx]: foo
|
||||
|
||||
......
|
||||
|
||||
<p data-sourcepos="1:1-1:8"><sup data-sourcepos="1:1-1:8" class="footnote-ref"><a href="#fn1" id="fnref1">[1]</a></sup></p>
|
||||
<hr class="footnotes-sep">
|
||||
<section class="footnotes">
|
||||
<ol class="footnotes-list">
|
||||
<li data-sourcepos="3:1-3:14" id="fn1" class="footnote-item">
|
||||
<p data-sourcepos="3:12-3:14">foo <a href="#fnref1" class="footnote-backref">↩︎</a></p>
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
37
crates/lib/md-footnote/tests/fixtures/9.md
vendored
Normal file
37
crates/lib/md-footnote/tests/fixtures/9.md
vendored
Normal file
@@ -0,0 +1,37 @@
|
||||
Nested blocks
|
||||
|
||||
......
|
||||
|
||||
[^a]
|
||||
|
||||
[^a]: abc
|
||||
|
||||
def
|
||||
hij
|
||||
|
||||
- list
|
||||
|
||||
> block
|
||||
|
||||
terminates here
|
||||
|
||||
......
|
||||
|
||||
<p data-sourcepos="1:1-1:4"><sup data-sourcepos="1:1-1:4" class="footnote-ref"><a href="#fn1" id="fnref1">[1]</a></sup></p>
|
||||
<p data-sourcepos="12:1-12:15">terminates here</p>
|
||||
<hr class="footnotes-sep">
|
||||
<section class="footnotes">
|
||||
<ol class="footnotes-list">
|
||||
<li data-sourcepos="3:1-11:0" id="fn1" class="footnote-item">
|
||||
<p data-sourcepos="3:7-3:9">abc</p>
|
||||
<p data-sourcepos="5:5-6:3">def
|
||||
hij</p>
|
||||
<ul data-sourcepos="8:5-9:0">
|
||||
<li data-sourcepos="8:5-9:0">list</li>
|
||||
</ul>
|
||||
<blockquote data-sourcepos="10:5-10:11">
|
||||
<p data-sourcepos="10:7-10:11">block</p>
|
||||
</blockquote>
|
||||
<a href="#fnref1" class="footnote-backref">↩︎</a></li>
|
||||
</ol>
|
||||
</section>
|
||||
24
crates/lib/md-footnote/tests/fixtures/_7.md
vendored
Normal file
24
crates/lib/md-footnote/tests/fixtures/_7.md
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
Indents for the first line
|
||||
.............
|
||||
|
||||
[^xxxxx] [^yyyyy]
|
||||
|
||||
[^xxxxx]: foo
|
||||
|
||||
[^yyyyy]: foo
|
||||
|
||||
.............
|
||||
|
||||
<p><sup class="footnote-ref"><a href="#fn1" id="fnref1">[1]</a></sup> <sup class="footnote-ref"><a href="#fn2" id="fnref2">[2]</a></sup></p>
|
||||
<hr class="footnotes-sep">
|
||||
<section class="footnotes">
|
||||
<ol class="footnotes-list">
|
||||
<li id="fn1" class="footnote-item">
|
||||
<p>foo <a href="#fnref1" class="footnote-backref">↩︎</a></p>
|
||||
</li>
|
||||
<li id="fn2" class="footnote-item">
|
||||
<pre><code>foo
|
||||
</code></pre>
|
||||
<a href="#fnref2" class="footnote-backref">↩︎</a></li>
|
||||
</ol>
|
||||
</section>
|
||||
21
crates/lib/md-footnote/tests/fixtures/inline-1.md
vendored
Normal file
21
crates/lib/md-footnote/tests/fixtures/inline-1.md
vendored
Normal file
@@ -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.]
|
||||
|
||||
......
|
||||
|
||||
<p data-sourcepos="1:1-3:6">Here is an inline note.<sup class="footnote-ref"><a href="#fn1" id="fnref1">[1]</a></sup></p>
|
||||
<hr class="footnotes-sep">
|
||||
<section class="footnotes">
|
||||
<ol class="footnotes-list">
|
||||
<li id="fn1" class="footnote-item">
|
||||
<p>Inlines notes are easier to write, since
|
||||
you don't have to pick an identifier and move down to type the
|
||||
note. <a href="#fnref1" class="footnote-backref">↩︎</a></p>
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
17
crates/lib/md-footnote/tests/fixtures/inline-2.md
vendored
Normal file
17
crates/lib/md-footnote/tests/fixtures/inline-2.md
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
Inline footnotes can have arbitrary markup
|
||||
|
||||
......
|
||||
|
||||
foo^[ *bar* ]
|
||||
|
||||
......
|
||||
|
||||
<p data-sourcepos="1:1-1:13">foo<sup class="footnote-ref"><a href="#fn1" id="fnref1">[1]</a></sup></p>
|
||||
<hr class="footnotes-sep">
|
||||
<section class="footnotes">
|
||||
<ol class="footnotes-list">
|
||||
<li id="fn1" class="footnote-item">
|
||||
<p> <em data-sourcepos="1:7-1:11">bar</em> <a href="#fnref1" class="footnote-backref">↩︎</a></p>
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
19
crates/lib/md-footnote/tests/fixtures/inline-3.md
vendored
Normal file
19
crates/lib/md-footnote/tests/fixtures/inline-3.md
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
Should allow links in inline footnotes
|
||||
|
||||
......
|
||||
|
||||
Example^[this is another example [a]]
|
||||
|
||||
[a]: https://github.com
|
||||
|
||||
......
|
||||
|
||||
<p data-sourcepos="1:1-1:37">Example<sup class="footnote-ref"><a href="#fn1" id="fnref1">[1]</a></sup></p>
|
||||
<hr class="footnotes-sep">
|
||||
<section class="footnotes">
|
||||
<ol class="footnotes-list">
|
||||
<li id="fn1" class="footnote-item">
|
||||
<p>this is another example <a data-sourcepos="1:34-1:36" href="https://github.com">a</a> <a href="#fnref1" class="footnote-backref">↩︎</a></p>
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
19
crates/lib/md-footnote/tests/fixtures/inline-4.md
vendored
Normal file
19
crates/lib/md-footnote/tests/fixtures/inline-4.md
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
nested inline footnotes
|
||||
|
||||
......
|
||||
|
||||
[Example^[this is another example [a]]][a]
|
||||
|
||||
[a]: https://github.com
|
||||
|
||||
......
|
||||
|
||||
<p data-sourcepos="1:1-1:42"><a data-sourcepos="1:1-1:42" href="https://github.com">Example<sup class="footnote-ref"><a href="#fn1" id="fnref1">[1]</a></sup></a></p>
|
||||
<hr class="footnotes-sep">
|
||||
<section class="footnotes">
|
||||
<ol class="footnotes-list">
|
||||
<li id="fn1" class="footnote-item">
|
||||
<p>this is another example <a data-sourcepos="1:35-1:37" href="https://github.com">a</a> <a href="#fnref1" class="footnote-backref">↩︎</a></p>
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
@@ -15,10 +15,12 @@ assetserver = { workspace = true }
|
||||
toolbox = { workspace = true }
|
||||
page = { workspace = true }
|
||||
|
||||
md-footnote = { workspace = true }
|
||||
|
||||
markdown-it = { workspace = true }
|
||||
axum = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
maud = { workspace = true }
|
||||
markdown-it = { workspace = true }
|
||||
emojis = { workspace = true }
|
||||
strum = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
|
||||
@@ -43,33 +43,42 @@ a:hover {
|
||||
transition: 150ms;
|
||||
}
|
||||
|
||||
|
||||
footer {
|
||||
font-size: 1.4rem;
|
||||
clear: both;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
|
||||
footer {
|
||||
text-align: left
|
||||
}
|
||||
|
||||
|
||||
|
||||
.footnote-definition {
|
||||
margin: 0 0 0 2rem;
|
||||
}
|
||||
|
||||
.footnote-definition-label {
|
||||
color: var(--metaColor);
|
||||
}
|
||||
|
||||
.footnote-definition p {
|
||||
.footnote-item p {
|
||||
display: inline;
|
||||
padding: 0 0 0 1rem;
|
||||
}
|
||||
|
||||
hr.footnotes-sep {
|
||||
margin: 5rem 0 0 0;
|
||||
}
|
||||
|
||||
.footnote-ref > a {
|
||||
padding: 0 2pt 0.8rem 2pt !important;
|
||||
}
|
||||
|
||||
a.footnote-backref, .footnote-ref > a
|
||||
{
|
||||
color: var(--metaColor);
|
||||
padding: 0 2pt 0 2pt;
|
||||
}
|
||||
|
||||
a.footnote-backref:hover,
|
||||
.footnote-ref > a:hover
|
||||
{
|
||||
color: var(--bgColor);
|
||||
background-color: var(--metaColor);
|
||||
}
|
||||
|
||||
.footContainer {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@@ -1,541 +0,0 @@
|
||||
use lazy_static::lazy_static;
|
||||
use markdown_it::generics::inline::full_link;
|
||||
use markdown_it::parser::block::{BlockRule, BlockState};
|
||||
use markdown_it::parser::core::Root;
|
||||
use markdown_it::parser::inline::{InlineRule, InlineState};
|
||||
use markdown_it::{MarkdownIt, Node, NodeValue, Renderer};
|
||||
use maud::{Markup, PreEscaped, Render, html};
|
||||
use page::{Page, PageMetadata, RequestContext};
|
||||
use std::str::FromStr;
|
||||
|
||||
use crate::components::fa::FAIcon;
|
||||
use crate::components::mangle::{MangledBetaEmail, MangledGoogleEmail};
|
||||
|
||||
lazy_static! {
|
||||
static ref MdParser: MarkdownIt = {
|
||||
let mut md = markdown_it::MarkdownIt::new();
|
||||
|
||||
{
|
||||
|
||||
use markdown_it::plugins::cmark::*;
|
||||
|
||||
inline::newline::add(&mut md);
|
||||
inline::escape::add(&mut md);
|
||||
inline::backticks::add(&mut md);
|
||||
inline::emphasis::add(&mut md);
|
||||
|
||||
// Replaced with smart links
|
||||
//inline::link::add(&mut md);
|
||||
full_link::add::<false>(&mut md, |href, title| {
|
||||
Node::new(SmartLink {
|
||||
url: href.unwrap_or_default(),
|
||||
title,
|
||||
})
|
||||
});
|
||||
|
||||
|
||||
inline::image::add(&mut md);
|
||||
inline::autolink::add(&mut md);
|
||||
inline::entity::add(&mut md);
|
||||
|
||||
block::code::add(&mut md);
|
||||
block::fence::add(&mut md);
|
||||
block::blockquote::add(&mut md);
|
||||
block::hr::add(&mut md);
|
||||
block::list::add(&mut md);
|
||||
block::reference::add(&mut md);
|
||||
block::heading::add(&mut md);
|
||||
block::lheading::add(&mut md);
|
||||
block::paragraph::add(&mut md);
|
||||
|
||||
}
|
||||
|
||||
markdown_it::plugins::html::add(&mut md);
|
||||
|
||||
md.block.add_rule::<YamlFrontMatter>().before_all();
|
||||
md.block.add_rule::<TomlFrontMatter>().before_all();
|
||||
|
||||
md.inline.add_rule::<InlineEmote>();
|
||||
md.inline.add_rule::<InlineEmote>();
|
||||
md.inline.add_rule::<InlineMdx>();
|
||||
|
||||
md
|
||||
};
|
||||
}
|
||||
|
||||
pub struct Markdown<'a>(pub &'a str);
|
||||
|
||||
impl Render for Markdown<'_> {
|
||||
fn render(&self) -> Markup {
|
||||
let md = Self::parse(self.0);
|
||||
let html = md.render();
|
||||
return PreEscaped(html);
|
||||
}
|
||||
}
|
||||
|
||||
impl Markdown<'_> {
|
||||
pub fn parse(md_str: &str) -> Node {
|
||||
MdParser.parse(md_str)
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// MARK: helpers
|
||||
//
|
||||
|
||||
/// Try to read page metadata from a markdown file's frontmatter.
|
||||
/// - returns `none` if there is no frontmatter
|
||||
/// - returns an error if we fail to parse frontmatter
|
||||
pub fn meta_from_markdown(root_node: &Node) -> Result<Option<PageMetadata>, toml::de::Error> {
|
||||
root_node
|
||||
.children
|
||||
.first()
|
||||
.and_then(|x| x.cast::<TomlFrontMatter>())
|
||||
.map(|x| toml::from_str::<PageMetadata>(&x.content))
|
||||
.map_or(Ok(None), |v| v.map(Some))
|
||||
}
|
||||
|
||||
pub fn page_from_markdown(md: impl Into<String>, default_image: Option<String>) -> Page {
|
||||
let md: String = md.into();
|
||||
let md = Markdown::parse(&md);
|
||||
|
||||
let mut meta = meta_from_markdown(&md)
|
||||
.unwrap_or(Some(PageMetadata {
|
||||
title: "Invalid frontmatter!".into(),
|
||||
..Default::default()
|
||||
}))
|
||||
.unwrap_or_default();
|
||||
|
||||
if meta.image.is_none() {
|
||||
meta.image = default_image
|
||||
}
|
||||
|
||||
let html = PreEscaped(md.render());
|
||||
|
||||
Page {
|
||||
meta,
|
||||
generate_html: Box::new(move |page, ctx| {
|
||||
let html = html.clone();
|
||||
|
||||
Box::pin(async move {
|
||||
html! {
|
||||
@if let Some(backlinks) = backlinks(page, ctx) {
|
||||
(backlinks)
|
||||
}
|
||||
|
||||
(html)
|
||||
}
|
||||
})
|
||||
}),
|
||||
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn backlinks(page: &Page, ctx: &RequestContext) -> Option<Markup> {
|
||||
let mut last = None;
|
||||
let mut backlinks = vec![("/", "home")];
|
||||
|
||||
if page.meta.backlinks.unwrap_or(false) {
|
||||
let mut segments = ctx.route.split("/").skip(1).collect::<Vec<_>>();
|
||||
last = segments.pop();
|
||||
|
||||
let mut end = 0;
|
||||
for s in segments {
|
||||
end += s.len();
|
||||
backlinks.push((&ctx.route[0..=end], s));
|
||||
end += 1; // trailing slash
|
||||
}
|
||||
}
|
||||
|
||||
last.map(|last| {
|
||||
html! {
|
||||
div {
|
||||
@for (url, text) in backlinks {
|
||||
a href=(url) style="padding-left:5pt;padding-right:5pt;" { (text) }
|
||||
"/"
|
||||
}
|
||||
|
||||
span style="color:var(--metaColor);padding-left:5pt;padding-right:5pt;" { (last) }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
//
|
||||
// MARK: extensions
|
||||
//
|
||||
|
||||
//
|
||||
// MARK: smart link
|
||||
//
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct SmartLink {
|
||||
pub url: String,
|
||||
pub title: Option<String>,
|
||||
}
|
||||
|
||||
impl NodeValue for SmartLink {
|
||||
fn render(&self, node: &Node, fmt: &mut dyn Renderer) {
|
||||
let mut attrs = node.attrs.clone();
|
||||
attrs.push(("href", self.url.clone()));
|
||||
|
||||
if let Some(title) = &self.title {
|
||||
attrs.push(("title", title.clone()));
|
||||
}
|
||||
|
||||
let external = !(self.url.starts_with(".") || self.url.starts_with("/"));
|
||||
|
||||
// Open external links in a new tab
|
||||
if external {
|
||||
attrs.push(("target", "_blank".into()));
|
||||
attrs.push(("rel", "noopener noreferrer".into()));
|
||||
}
|
||||
|
||||
fmt.open("a", &attrs);
|
||||
fmt.contents(&node.children);
|
||||
fmt.close("a");
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// MARK: emote
|
||||
//
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct InlineEmote(String);
|
||||
|
||||
impl NodeValue for InlineEmote {
|
||||
fn render(&self, _node: &Node, fmt: &mut dyn Renderer) {
|
||||
fmt.text_raw(self.0.as_str());
|
||||
}
|
||||
}
|
||||
|
||||
impl InlineRule for InlineEmote {
|
||||
const MARKER: char = ':';
|
||||
|
||||
fn run(state: &mut InlineState<'_, '_>) -> Option<(Node, usize)> {
|
||||
let input = &state.src[state.pos..state.pos_max];
|
||||
|
||||
if !input.starts_with(':') {
|
||||
return None;
|
||||
}
|
||||
|
||||
let end_idx = input[1..].find(':')? + 1;
|
||||
let code = &input[1..end_idx];
|
||||
|
||||
let mut emote = None;
|
||||
|
||||
if emote.is_none()
|
||||
&& let Some(code) = code.strip_prefix("fa-")
|
||||
{
|
||||
emote = FAIcon::from_str(code).ok().map(|x| x.render().0)
|
||||
}
|
||||
|
||||
if emote.is_none() {
|
||||
emote = emojis::get_by_shortcode(code).map(|x| x.to_string());
|
||||
}
|
||||
|
||||
Some((Node::new(InlineEmote(emote?)), end_idx + 1))
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// MARK: mdx
|
||||
//
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct InlineMdx(String);
|
||||
|
||||
impl NodeValue for InlineMdx {
|
||||
fn render(&self, node: &Node, fmt: &mut dyn Renderer) {
|
||||
if mdx_style(&self.0, node, fmt) {
|
||||
return;
|
||||
}
|
||||
|
||||
if mdx_include(&self.0, node, fmt) {
|
||||
return;
|
||||
}
|
||||
|
||||
fmt.open("code", &[]);
|
||||
fmt.text(&self.0);
|
||||
fmt.close("code");
|
||||
}
|
||||
}
|
||||
|
||||
impl InlineRule for InlineMdx {
|
||||
const MARKER: char = '{';
|
||||
|
||||
fn run(state: &mut InlineState<'_, '_>) -> Option<(Node, usize)> {
|
||||
let input = &state.src[state.pos..state.pos_max];
|
||||
if !input.starts_with('{') {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut balance = 1;
|
||||
let mut end = 1;
|
||||
for i in input[1..].bytes() {
|
||||
match i {
|
||||
b'}' => balance -= 1,
|
||||
b'{' => balance += 1,
|
||||
_ => {}
|
||||
}
|
||||
|
||||
if balance == 0 {
|
||||
break;
|
||||
}
|
||||
|
||||
end += 1;
|
||||
}
|
||||
|
||||
if balance != 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let content = &input[1..end];
|
||||
Some((Node::new(InlineMdx(content.to_owned())), content.len() + 2))
|
||||
}
|
||||
}
|
||||
|
||||
fn mdx_style(mdx: &str, _node: &Node, fmt: &mut dyn Renderer) -> bool {
|
||||
// Parse inside of mdx: `color(value, "text")`
|
||||
let mdx = mdx
|
||||
.trim()
|
||||
.trim_start_matches('{')
|
||||
.trim_end_matches('}')
|
||||
.trim();
|
||||
|
||||
// Find the function name (everything before the opening parenthesis)
|
||||
let paren_pos = match mdx.find('(') {
|
||||
Some(x) => x,
|
||||
None => return false,
|
||||
};
|
||||
|
||||
if mdx[..paren_pos].trim() != "color" {
|
||||
return false;
|
||||
};
|
||||
|
||||
// Find matching closing parenthesis
|
||||
let skip = paren_pos + 1;
|
||||
let mut balance = 1;
|
||||
let mut end = skip;
|
||||
for i in mdx[skip..].bytes() {
|
||||
match i {
|
||||
b')' => balance -= 1,
|
||||
b'(' => balance += 1,
|
||||
_ => {}
|
||||
}
|
||||
|
||||
if balance == 0 {
|
||||
break;
|
||||
}
|
||||
|
||||
end += 1;
|
||||
}
|
||||
|
||||
if balance != 0 {
|
||||
return false;
|
||||
}
|
||||
|
||||
let args = mdx[skip..end].trim();
|
||||
|
||||
// Parse arguments: should be "value, text" or "value, \"text\""
|
||||
let comma_pos = match args.find(',') {
|
||||
Some(x) => x,
|
||||
None => return false,
|
||||
};
|
||||
|
||||
let value = args[..comma_pos].trim();
|
||||
let text = args[comma_pos + 1..].trim();
|
||||
|
||||
// Strip quotes from text if present
|
||||
let text = if (text.starts_with('"') && text.ends_with('"'))
|
||||
|| (text.starts_with('\'') && text.ends_with('\''))
|
||||
{
|
||||
&text[1..text.len() - 1]
|
||||
} else {
|
||||
text
|
||||
};
|
||||
|
||||
let mut style_str = String::new();
|
||||
|
||||
if value.starts_with("#") {
|
||||
style_str.push_str("color:");
|
||||
style_str.push_str(value);
|
||||
style_str.push(';');
|
||||
} else if value.starts_with("--") {
|
||||
style_str.push_str("color:var(");
|
||||
style_str.push_str(value);
|
||||
style_str.push_str(");");
|
||||
} else {
|
||||
style_str.push_str("color:");
|
||||
style_str.push_str(value);
|
||||
style_str.push(';');
|
||||
}
|
||||
|
||||
fmt.open("span", &[("style", style_str)]);
|
||||
fmt.text(text);
|
||||
fmt.close("span");
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
fn mdx_include(mdx: &str, _node: &Node, fmt: &mut dyn Renderer) -> bool {
|
||||
// Parse inside of mdx: `include(<args>)`
|
||||
let args = {
|
||||
let mdx = mdx
|
||||
.trim()
|
||||
.trim_start_matches('{')
|
||||
.trim_end_matches('}')
|
||||
.trim();
|
||||
|
||||
if !mdx.starts_with("include(") {
|
||||
return false;
|
||||
}
|
||||
|
||||
let skip = 8;
|
||||
let mut balance = 1;
|
||||
let mut end = skip;
|
||||
for i in mdx[skip..].bytes() {
|
||||
match i {
|
||||
b')' => balance -= 1,
|
||||
b'(' => balance += 1,
|
||||
_ => {}
|
||||
}
|
||||
|
||||
if balance == 0 {
|
||||
break;
|
||||
}
|
||||
|
||||
end += 1;
|
||||
}
|
||||
|
||||
if balance != 0 {
|
||||
return false;
|
||||
}
|
||||
|
||||
let args = mdx[skip..end].trim();
|
||||
let trail = mdx[end + 1..].trim();
|
||||
if !trail.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
args
|
||||
};
|
||||
|
||||
let str = match args {
|
||||
"email_beta" => MangledBetaEmail {}.render().0,
|
||||
"email_goog" => MangledGoogleEmail {}.render().0,
|
||||
_ => return false,
|
||||
};
|
||||
|
||||
fmt.text_raw(&str);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
//
|
||||
// MARK: yaml frontmatter
|
||||
//
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct YamlFrontMatter {
|
||||
#[expect(dead_code)]
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
impl NodeValue for YamlFrontMatter {
|
||||
fn render(&self, _node: &Node, _fmt: &mut dyn Renderer) {}
|
||||
}
|
||||
|
||||
impl BlockRule for YamlFrontMatter {
|
||||
fn run(state: &mut BlockState<'_, '_>) -> Option<(Node, usize)> {
|
||||
// check the parent is the document Root
|
||||
if !state.node.is::<Root>() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// check we are on the first line of the document
|
||||
if state.line != 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
// check line starts with opening dashes
|
||||
let opening = state
|
||||
.get_line(state.line)
|
||||
.chars()
|
||||
.take_while(|c| *c == '-')
|
||||
.collect::<String>();
|
||||
if !opening.starts_with("---") {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Search for the end of the block
|
||||
let mut next_line = state.line;
|
||||
loop {
|
||||
next_line += 1;
|
||||
if next_line >= state.line_max {
|
||||
return None;
|
||||
}
|
||||
|
||||
let line = state.get_line(next_line);
|
||||
if line.starts_with(&opening) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let (content, _) = state.get_lines(state.line + 1, next_line, 0, true);
|
||||
Some((Node::new(YamlFrontMatter { content }), next_line + 1))
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// MARK: toml frontmatter
|
||||
//
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct TomlFrontMatter {
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
impl NodeValue for TomlFrontMatter {
|
||||
fn render(&self, _node: &Node, _fmt: &mut dyn Renderer) {}
|
||||
}
|
||||
|
||||
impl BlockRule for TomlFrontMatter {
|
||||
fn run(state: &mut BlockState<'_, '_>) -> Option<(Node, usize)> {
|
||||
if !state.node.is::<Root>() {
|
||||
return None;
|
||||
}
|
||||
|
||||
if state.line != 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let opening = state
|
||||
.get_line(state.line)
|
||||
.chars()
|
||||
.take_while(|c| *c == '+')
|
||||
.collect::<String>();
|
||||
if !opening.starts_with("+++") {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut next_line = state.line;
|
||||
loop {
|
||||
next_line += 1;
|
||||
if next_line >= state.line_max {
|
||||
return None;
|
||||
}
|
||||
|
||||
let line = state.get_line(next_line);
|
||||
if line.starts_with(&opening) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let (content, _) = state.get_lines(state.line + 1, next_line, 0, true);
|
||||
Some((Node::new(TomlFrontMatter { content }), next_line + 1))
|
||||
}
|
||||
}
|
||||
45
crates/service/service-webpage/src/components/md/emote.rs
Normal file
45
crates/service/service-webpage/src/components/md/emote.rs
Normal file
@@ -0,0 +1,45 @@
|
||||
use std::str::FromStr;
|
||||
|
||||
use markdown_it::parser::inline::{InlineRule, InlineState};
|
||||
use markdown_it::{Node, NodeValue, Renderer};
|
||||
use maud::Render;
|
||||
|
||||
use crate::components::fa::FAIcon;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct InlineEmote(String);
|
||||
|
||||
impl NodeValue for InlineEmote {
|
||||
fn render(&self, _node: &Node, fmt: &mut dyn Renderer) {
|
||||
fmt.text_raw(self.0.as_str());
|
||||
}
|
||||
}
|
||||
|
||||
impl InlineRule for InlineEmote {
|
||||
const MARKER: char = ':';
|
||||
|
||||
fn run(state: &mut InlineState<'_, '_>) -> Option<(Node, usize)> {
|
||||
let input = &state.src[state.pos..state.pos_max];
|
||||
|
||||
if !input.starts_with(':') {
|
||||
return None;
|
||||
}
|
||||
|
||||
let end_idx = input[1..].find(':')? + 1;
|
||||
let code = &input[1..end_idx];
|
||||
|
||||
let mut emote = None;
|
||||
|
||||
if emote.is_none()
|
||||
&& let Some(code) = code.strip_prefix("fa-")
|
||||
{
|
||||
emote = FAIcon::from_str(code).ok().map(|x| x.render().0)
|
||||
}
|
||||
|
||||
if emote.is_none() {
|
||||
emote = emojis::get_by_shortcode(code).map(|x| x.to_string());
|
||||
}
|
||||
|
||||
Some((Node::new(InlineEmote(emote?)), end_idx + 1))
|
||||
}
|
||||
}
|
||||
108
crates/service/service-webpage/src/components/md/frontmatter.rs
Normal file
108
crates/service/service-webpage/src/components/md/frontmatter.rs
Normal file
@@ -0,0 +1,108 @@
|
||||
use markdown_it::parser::block::{BlockRule, BlockState};
|
||||
use markdown_it::parser::core::Root;
|
||||
use markdown_it::{Node, NodeValue, Renderer};
|
||||
|
||||
//
|
||||
// MARK: yaml
|
||||
//
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct YamlFrontMatter {
|
||||
#[expect(dead_code)]
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
impl NodeValue for YamlFrontMatter {
|
||||
fn render(&self, _node: &Node, _fmt: &mut dyn Renderer) {}
|
||||
}
|
||||
|
||||
impl BlockRule for YamlFrontMatter {
|
||||
fn run(state: &mut BlockState<'_, '_>) -> Option<(Node, usize)> {
|
||||
// check the parent is the document Root
|
||||
if !state.node.is::<Root>() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// check we are on the first line of the document
|
||||
if state.line != 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
// check line starts with opening dashes
|
||||
let opening = state
|
||||
.get_line(state.line)
|
||||
.chars()
|
||||
.take_while(|c| *c == '-')
|
||||
.collect::<String>();
|
||||
if !opening.starts_with("---") {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Search for the end of the block
|
||||
let mut next_line = state.line;
|
||||
loop {
|
||||
next_line += 1;
|
||||
if next_line >= state.line_max {
|
||||
return None;
|
||||
}
|
||||
|
||||
let line = state.get_line(next_line);
|
||||
if line.starts_with(&opening) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let (content, _) = state.get_lines(state.line + 1, next_line, 0, true);
|
||||
Some((Node::new(YamlFrontMatter { content }), next_line + 1))
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// MARK: toml
|
||||
//
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct TomlFrontMatter {
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
impl NodeValue for TomlFrontMatter {
|
||||
fn render(&self, _node: &Node, _fmt: &mut dyn Renderer) {}
|
||||
}
|
||||
|
||||
impl BlockRule for TomlFrontMatter {
|
||||
fn run(state: &mut BlockState<'_, '_>) -> Option<(Node, usize)> {
|
||||
if !state.node.is::<Root>() {
|
||||
return None;
|
||||
}
|
||||
|
||||
if state.line != 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let opening = state
|
||||
.get_line(state.line)
|
||||
.chars()
|
||||
.take_while(|c| *c == '+')
|
||||
.collect::<String>();
|
||||
if !opening.starts_with("+++") {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut next_line = state.line;
|
||||
loop {
|
||||
next_line += 1;
|
||||
if next_line >= state.line_max {
|
||||
return None;
|
||||
}
|
||||
|
||||
let line = state.get_line(next_line);
|
||||
if line.starts_with(&opening) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let (content, _) = state.get_lines(state.line + 1, next_line, 0, true);
|
||||
Some((Node::new(TomlFrontMatter { content }), next_line + 1))
|
||||
}
|
||||
}
|
||||
30
crates/service/service-webpage/src/components/md/link.rs
Normal file
30
crates/service/service-webpage/src/components/md/link.rs
Normal file
@@ -0,0 +1,30 @@
|
||||
use markdown_it::{Node, NodeValue, Renderer};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct SmartLink {
|
||||
pub url: String,
|
||||
pub title: Option<String>,
|
||||
}
|
||||
|
||||
impl NodeValue for SmartLink {
|
||||
fn render(&self, node: &Node, fmt: &mut dyn Renderer) {
|
||||
let mut attrs = node.attrs.clone();
|
||||
attrs.push(("href", self.url.clone()));
|
||||
|
||||
if let Some(title) = &self.title {
|
||||
attrs.push(("title", title.clone()));
|
||||
}
|
||||
|
||||
let external = !(self.url.starts_with(".") || self.url.starts_with("/"));
|
||||
|
||||
// Open external links in a new tab
|
||||
if external {
|
||||
attrs.push(("target", "_blank".into()));
|
||||
attrs.push(("rel", "noopener noreferrer".into()));
|
||||
}
|
||||
|
||||
fmt.open("a", &attrs);
|
||||
fmt.contents(&node.children);
|
||||
fmt.close("a");
|
||||
}
|
||||
}
|
||||
195
crates/service/service-webpage/src/components/md/mdx.rs
Normal file
195
crates/service/service-webpage/src/components/md/mdx.rs
Normal file
@@ -0,0 +1,195 @@
|
||||
use markdown_it::parser::inline::{InlineRule, InlineState};
|
||||
use markdown_it::{Node, NodeValue, Renderer};
|
||||
use maud::Render;
|
||||
|
||||
use crate::components::mangle::{MangledBetaEmail, MangledGoogleEmail};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct InlineMdx(String);
|
||||
|
||||
impl NodeValue for InlineMdx {
|
||||
fn render(&self, node: &Node, fmt: &mut dyn Renderer) {
|
||||
if mdx_style(&self.0, node, fmt) {
|
||||
return;
|
||||
}
|
||||
|
||||
if mdx_include(&self.0, node, fmt) {
|
||||
return;
|
||||
}
|
||||
|
||||
fmt.open("code", &[]);
|
||||
fmt.text(&self.0);
|
||||
fmt.close("code");
|
||||
}
|
||||
}
|
||||
|
||||
impl InlineRule for InlineMdx {
|
||||
const MARKER: char = '{';
|
||||
|
||||
fn run(state: &mut InlineState<'_, '_>) -> Option<(Node, usize)> {
|
||||
let input = &state.src[state.pos..state.pos_max];
|
||||
if !input.starts_with('{') {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut balance = 1;
|
||||
let mut end = 1;
|
||||
for i in input[1..].bytes() {
|
||||
match i {
|
||||
b'}' => balance -= 1,
|
||||
b'{' => balance += 1,
|
||||
_ => {}
|
||||
}
|
||||
|
||||
if balance == 0 {
|
||||
break;
|
||||
}
|
||||
|
||||
end += 1;
|
||||
}
|
||||
|
||||
if balance != 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let content = &input[1..end];
|
||||
Some((Node::new(InlineMdx(content.to_owned())), content.len() + 2))
|
||||
}
|
||||
}
|
||||
|
||||
fn mdx_style(mdx: &str, _node: &Node, fmt: &mut dyn Renderer) -> bool {
|
||||
// Parse inside of mdx: `color(value, "text")`
|
||||
let mdx = mdx
|
||||
.trim()
|
||||
.trim_start_matches('{')
|
||||
.trim_end_matches('}')
|
||||
.trim();
|
||||
|
||||
// Find the function name (everything before the opening parenthesis)
|
||||
let paren_pos = match mdx.find('(') {
|
||||
Some(x) => x,
|
||||
None => return false,
|
||||
};
|
||||
|
||||
if mdx[..paren_pos].trim() != "color" {
|
||||
return false;
|
||||
};
|
||||
|
||||
// Find matching closing parenthesis
|
||||
let skip = paren_pos + 1;
|
||||
let mut balance = 1;
|
||||
let mut end = skip;
|
||||
for i in mdx[skip..].bytes() {
|
||||
match i {
|
||||
b')' => balance -= 1,
|
||||
b'(' => balance += 1,
|
||||
_ => {}
|
||||
}
|
||||
|
||||
if balance == 0 {
|
||||
break;
|
||||
}
|
||||
|
||||
end += 1;
|
||||
}
|
||||
|
||||
if balance != 0 {
|
||||
return false;
|
||||
}
|
||||
|
||||
let args = mdx[skip..end].trim();
|
||||
|
||||
// Parse arguments: should be "value, text" or "value, \"text\""
|
||||
let comma_pos = match args.find(',') {
|
||||
Some(x) => x,
|
||||
None => return false,
|
||||
};
|
||||
|
||||
let value = args[..comma_pos].trim();
|
||||
let text = args[comma_pos + 1..].trim();
|
||||
|
||||
// Strip quotes from text if present
|
||||
let text = if (text.starts_with('"') && text.ends_with('"'))
|
||||
|| (text.starts_with('\'') && text.ends_with('\''))
|
||||
{
|
||||
&text[1..text.len() - 1]
|
||||
} else {
|
||||
text
|
||||
};
|
||||
|
||||
let mut style_str = String::new();
|
||||
|
||||
if value.starts_with("#") {
|
||||
style_str.push_str("color:");
|
||||
style_str.push_str(value);
|
||||
style_str.push(';');
|
||||
} else if value.starts_with("--") {
|
||||
style_str.push_str("color:var(");
|
||||
style_str.push_str(value);
|
||||
style_str.push_str(");");
|
||||
} else {
|
||||
style_str.push_str("color:");
|
||||
style_str.push_str(value);
|
||||
style_str.push(';');
|
||||
}
|
||||
|
||||
fmt.open("span", &[("style", style_str)]);
|
||||
fmt.text(text);
|
||||
fmt.close("span");
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
fn mdx_include(mdx: &str, _node: &Node, fmt: &mut dyn Renderer) -> bool {
|
||||
// Parse inside of mdx: `include(<args>)`
|
||||
let args = {
|
||||
let mdx = mdx
|
||||
.trim()
|
||||
.trim_start_matches('{')
|
||||
.trim_end_matches('}')
|
||||
.trim();
|
||||
|
||||
if !mdx.starts_with("include(") {
|
||||
return false;
|
||||
}
|
||||
|
||||
let skip = 8;
|
||||
let mut balance = 1;
|
||||
let mut end = skip;
|
||||
for i in mdx[skip..].bytes() {
|
||||
match i {
|
||||
b')' => balance -= 1,
|
||||
b'(' => balance += 1,
|
||||
_ => {}
|
||||
}
|
||||
|
||||
if balance == 0 {
|
||||
break;
|
||||
}
|
||||
|
||||
end += 1;
|
||||
}
|
||||
|
||||
if balance != 0 {
|
||||
return false;
|
||||
}
|
||||
|
||||
let args = mdx[skip..end].trim();
|
||||
let trail = mdx[end + 1..].trim();
|
||||
if !trail.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
args
|
||||
};
|
||||
|
||||
let str = match args {
|
||||
"email_beta" => MangledBetaEmail {}.render().0,
|
||||
"email_goog" => MangledGoogleEmail {}.render().0,
|
||||
_ => return false,
|
||||
};
|
||||
|
||||
fmt.text_raw(&str);
|
||||
|
||||
return true;
|
||||
}
|
||||
169
crates/service/service-webpage/src/components/md/mod.rs
Normal file
169
crates/service/service-webpage/src/components/md/mod.rs
Normal file
@@ -0,0 +1,169 @@
|
||||
use lazy_static::lazy_static;
|
||||
use markdown_it::generics::inline::full_link;
|
||||
use markdown_it::{MarkdownIt, Node};
|
||||
use maud::{Markup, PreEscaped, Render, html};
|
||||
use page::{Page, PageMetadata, RequestContext};
|
||||
|
||||
use crate::components::md::emote::InlineEmote;
|
||||
use crate::components::md::frontmatter::{TomlFrontMatter, YamlFrontMatter};
|
||||
use crate::components::md::link::SmartLink;
|
||||
use crate::components::md::mdx::InlineMdx;
|
||||
|
||||
mod emote;
|
||||
mod frontmatter;
|
||||
mod link;
|
||||
mod mdx;
|
||||
|
||||
lazy_static! {
|
||||
static ref MdParser: MarkdownIt = {
|
||||
let mut md = markdown_it::MarkdownIt::new();
|
||||
|
||||
{
|
||||
|
||||
use markdown_it::plugins::cmark::*;
|
||||
|
||||
inline::newline::add(&mut md);
|
||||
inline::escape::add(&mut md);
|
||||
inline::backticks::add(&mut md);
|
||||
inline::emphasis::add(&mut md);
|
||||
|
||||
// Replaced with smart links
|
||||
//inline::link::add(&mut md);
|
||||
full_link::add::<false>(&mut md, |href, title| {
|
||||
Node::new(SmartLink {
|
||||
url: href.unwrap_or_default(),
|
||||
title,
|
||||
})
|
||||
});
|
||||
|
||||
|
||||
inline::image::add(&mut md);
|
||||
inline::autolink::add(&mut md);
|
||||
inline::entity::add(&mut md);
|
||||
|
||||
block::code::add(&mut md);
|
||||
block::fence::add(&mut md);
|
||||
block::blockquote::add(&mut md);
|
||||
block::hr::add(&mut md);
|
||||
block::list::add(&mut md);
|
||||
block::reference::add(&mut md);
|
||||
block::heading::add(&mut md);
|
||||
block::lheading::add(&mut md);
|
||||
block::paragraph::add(&mut md);
|
||||
|
||||
}
|
||||
|
||||
{
|
||||
markdown_it::plugins::html::add(&mut md);
|
||||
md_footnote::add(&mut md);
|
||||
}
|
||||
|
||||
|
||||
md.block.add_rule::<YamlFrontMatter>().before_all();
|
||||
md.block.add_rule::<TomlFrontMatter>().before_all();
|
||||
|
||||
md.inline.add_rule::<InlineEmote>();
|
||||
md.inline.add_rule::<InlineMdx>();
|
||||
|
||||
md
|
||||
};
|
||||
}
|
||||
|
||||
pub struct Markdown<'a>(pub &'a str);
|
||||
|
||||
impl Render for Markdown<'_> {
|
||||
fn render(&self) -> Markup {
|
||||
let md = Self::parse(self.0);
|
||||
let html = md.render();
|
||||
return PreEscaped(html);
|
||||
}
|
||||
}
|
||||
|
||||
impl Markdown<'_> {
|
||||
pub fn parse(md_str: &str) -> Node {
|
||||
MdParser.parse(md_str)
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// MARK: helpers
|
||||
//
|
||||
|
||||
/// Try to read page metadata from a markdown file's frontmatter.
|
||||
/// - returns `none` if there is no frontmatter
|
||||
/// - returns an error if we fail to parse frontmatter
|
||||
pub fn meta_from_markdown(root_node: &Node) -> Result<Option<PageMetadata>, toml::de::Error> {
|
||||
root_node
|
||||
.children
|
||||
.first()
|
||||
.and_then(|x| x.cast::<TomlFrontMatter>())
|
||||
.map(|x| toml::from_str::<PageMetadata>(&x.content))
|
||||
.map_or(Ok(None), |v| v.map(Some))
|
||||
}
|
||||
|
||||
pub fn page_from_markdown(md: impl Into<String>, default_image: Option<String>) -> Page {
|
||||
let md: String = md.into();
|
||||
let md = Markdown::parse(&md);
|
||||
|
||||
let mut meta = meta_from_markdown(&md)
|
||||
.unwrap_or(Some(PageMetadata {
|
||||
title: "Invalid frontmatter!".into(),
|
||||
..Default::default()
|
||||
}))
|
||||
.unwrap_or_default();
|
||||
|
||||
if meta.image.is_none() {
|
||||
meta.image = default_image
|
||||
}
|
||||
|
||||
let html = PreEscaped(md.render());
|
||||
|
||||
Page {
|
||||
meta,
|
||||
generate_html: Box::new(move |page, ctx| {
|
||||
let html = html.clone();
|
||||
|
||||
Box::pin(async move {
|
||||
html! {
|
||||
@if let Some(backlinks) = backlinks(page, ctx) {
|
||||
(backlinks)
|
||||
}
|
||||
|
||||
(html)
|
||||
}
|
||||
})
|
||||
}),
|
||||
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn backlinks(page: &Page, ctx: &RequestContext) -> Option<Markup> {
|
||||
let mut last = None;
|
||||
let mut backlinks = vec![("/", "home")];
|
||||
|
||||
if page.meta.backlinks.unwrap_or(false) {
|
||||
let mut segments = ctx.route.split("/").skip(1).collect::<Vec<_>>();
|
||||
last = segments.pop();
|
||||
|
||||
let mut end = 0;
|
||||
for s in segments {
|
||||
end += s.len();
|
||||
backlinks.push((&ctx.route[0..=end], s));
|
||||
end += 1; // trailing slash
|
||||
}
|
||||
}
|
||||
|
||||
last.map(|last| {
|
||||
html! {
|
||||
div {
|
||||
@for (url, text) in backlinks {
|
||||
a href=(url) style="padding-left:5pt;padding-right:5pt;" { (text) }
|
||||
"/"
|
||||
}
|
||||
|
||||
span style="color:var(--metaColor);padding-left:5pt;padding-right:5pt;" { (last) }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -165,7 +165,7 @@ The document itself should also be numbered. In most cases, a `\today` on the fr
|
||||
|
||||
This helps synchronize the handout you _think_ the class has with the handout that the class _really_ has.
|
||||
|
||||
Future instructors {{color(--grey, "(and future you")}} will be thankful.
|
||||
Future instructors {{color(--grey, "(and future you)")}} will be thankful.
|
||||
|
||||
### Items
|
||||
|
||||
|
||||
Reference in New Issue
Block a user