Compare commits

...

3 Commits

Author SHA1 Message Date
a3ff195de9 Footnotes
Some checks failed
CI / Check typos (push) Successful in 8s
CI / Check links (push) Failing after 12s
CI / Clippy (push) Successful in 54s
CI / Build and test (push) Successful in 1m11s
CI / Build container (push) Successful in 52s
CI / Deploy on waypoint (push) Successful in 44s
2025-11-06 22:20:01 -08:00
d508a0d031 Bacon 2025-11-06 22:16:15 -08:00
dc4260e147 Add md-footnote 2025-11-06 21:16:09 -08:00
38 changed files with 2570 additions and 563 deletions

518
Cargo.lock generated
View File

@@ -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"

View File

@@ -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
View 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"

View 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 }

View 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
);
}
}

View 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 }

View 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)

View 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("&nbsp;");
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&nbsp;\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;
}
}

View 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;
}
}

View 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")
}
}

View 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
}

View 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(),
}
}
}

View 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,
))
}
}

View 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);
}
*/

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -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 }

View File

@@ -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;

View File

@@ -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))
}
}

View 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))
}
}

View 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))
}
}

View 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");
}
}

View 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;
}

View 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) }
}
}
})
}

View File

@@ -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