sable |
| an obsidian renderer |
| git clone https://git.wayver.dev/sable |
| README | tree | log | refs |
Commit: 2b84405277e54ab809e328cf0237374d4b4dbd0c (tree)
Author: wayverd
Date: 2026 M02 23, Mon 01:55:03 -0500
139 files changed; 17808 insertions 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..63225eb
+/target
+
+/node_modules
+/package.json
+/pnpm-lock.yaml
+/pnpm-workspace.yaml
+
+/dist
+/content
+/static
+/templates
+/sable.kdl
+/sable.toml
diff --git a/Cargo.lock b/Cargo.lock
new file mode 100644
index 0000000..369c701
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "addr2line"
+version = "0.25.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b"
+dependencies = [
+ "gimli",
+]
+
+[[package]]
+name = "adler2"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
+
+[[package]]
+name = "ahash"
+version = "0.8.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
+dependencies = [
+ "cfg-if",
+ "getrandom 0.3.4",
+ "once_cell",
+ "version_check",
+ "zerocopy",
+]
+
+[[package]]
+name = "aho-corasick"
+version = "1.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "allocator-api2"
+version = "0.2.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
+
+[[package]]
+name = "android_system_properties"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "anstream"
+version = "0.6.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a"
+dependencies = [
+ "anstyle",
+ "anstyle-parse",
+ "anstyle-query",
+ "anstyle-wincon",
+ "colorchoice",
+ "is_terminal_polyfill",
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle"
+version = "1.0.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78"
+
+[[package]]
+name = "anstyle-parse"
+version = "0.2.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2"
+dependencies = [
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle-query"
+version = "1.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "anstyle-wincon"
+version = "3.0.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
+dependencies = [
+ "anstyle",
+ "once_cell_polyfill",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "anyhow"
+version = "1.0.101"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea"
+
+[[package]]
+name = "ar_archive_writer"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7eb93bbb63b9c227414f6eb3a0adfddca591a8ce1e9b60661bb08969b87e340b"
+dependencies = [
+ "object",
+]
+
+[[package]]
+name = "arrayvec"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23b62fc65de8e4e7f52534fb52b0f3ed04746ae267519eef2a83941e8085068b"
+
+[[package]]
+name = "arrayvec"
+version = "0.7.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
+
+[[package]]
+name = "askama_escape"
+version = "0.15.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7416d225cbbaf6dbd8c983d3544adfa5688e2184bf404c6d8d0d9b53c59a9a0a"
+
+[[package]]
+name = "atomic-waker"
+version = "1.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
+
+[[package]]
+name = "autocfg"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
+
+[[package]]
+name = "axum"
+version = "0.8.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8"
+dependencies = [
+ "axum-core",
+ "base64",
+ "bytes",
+ "form_urlencoded",
+ "futures-util",
+ "http",
+ "http-body",
+ "http-body-util",
+ "hyper",
+ "hyper-util",
+ "itoa",
+ "matchit",
+ "memchr",
+ "mime",
+ "percent-encoding",
+ "pin-project-lite",
+ "serde_core",
+ "serde_json",
+ "serde_path_to_error",
+ "serde_urlencoded",
+ "sha1",
+ "sync_wrapper",
+ "tokio",
+ "tokio-tungstenite",
+ "tower",
+ "tower-layer",
+ "tower-service",
+ "tracing",
+]
+
+[[package]]
+name = "axum-core"
+version = "0.5.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "http",
+ "http-body",
+ "http-body-util",
+ "mime",
+ "pin-project-lite",
+ "sync_wrapper",
+ "tower-layer",
+ "tower-service",
+ "tracing",
+]
+
+[[package]]
+name = "backtrace"
+version = "0.3.76"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6"
+dependencies = [
+ "addr2line",
+ "cfg-if",
+ "libc",
+ "miniz_oxide",
+ "object",
+ "rustc-demangle",
+ "windows-link",
+]
+
+[[package]]
+name = "backtrace-ext"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "537beee3be4a18fb023b570f80e3ae28003db9167a751266b259926e25539d50"
+dependencies = [
+ "backtrace",
+]
+
+[[package]]
+name = "base64"
+version = "0.22.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
+
+[[package]]
+name = "bincode"
+version = "1.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "bitflags"
+version = "1.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
+
+[[package]]
+name = "bitflags"
+version = "2.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3"
+
+[[package]]
+name = "block-buffer"
+version = "0.10.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
+dependencies = [
+ "generic-array",
+]
+
+[[package]]
+name = "bstr"
+version = "1.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab"
+dependencies = [
+ "memchr",
+ "serde",
+]
+
+[[package]]
+name = "bumpalo"
+version = "3.19.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510"
+
+[[package]]
+name = "bytes"
+version = "1.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
+
+[[package]]
+name = "camino"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "cc"
+version = "1.2.55"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47b26a0954ae34af09b50f0de26458fa95369a0d478d8236d3f93082b219bd29"
+dependencies = [
+ "find-msvc-tools",
+ "shlex",
+]
+
+[[package]]
+name = "cfg-if"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
+
+[[package]]
+name = "chrono"
+version = "0.4.43"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118"
+dependencies = [
+ "iana-time-zone",
+ "num-traits",
+ "windows-link",
+]
+
+[[package]]
+name = "chrono-tz"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93698b29de5e97ad0ae26447b344c482a7284c737d9ddc5f9e52b74a336671bb"
+dependencies = [
+ "chrono",
+ "chrono-tz-build",
+ "phf",
+]
+
+[[package]]
+name = "chrono-tz-build"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c088aee841df9c3041febbb73934cfc39708749bf96dc827e3359cd39ef11b1"
+dependencies = [
+ "parse-zoneinfo",
+ "phf",
+ "phf_codegen",
+]
+
+[[package]]
+name = "chumsky"
+version = "0.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ba4a05c9ce83b07de31b31c874e87c069881ac4355db9e752e3a55c11ec75a6"
+dependencies = [
+ "hashbrown 0.15.5",
+ "regex-automata 0.3.9",
+ "serde",
+ "stacker",
+ "unicode-ident",
+ "unicode-segmentation",
+]
+
+[[package]]
+name = "clap"
+version = "4.5.57"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6899ea499e3fb9305a65d5ebf6e3d2248c5fab291f300ad0a704fbe142eae31a"
+dependencies = [
+ "clap_builder",
+ "clap_derive",
+]
+
+[[package]]
+name = "clap_builder"
+version = "4.5.57"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7b12c8b680195a62a8364d16b8447b01b6c2c8f9aaf68bee653be34d4245e238"
+dependencies = [
+ "anstream",
+ "anstyle",
+ "clap_lex",
+ "strsim",
+ "terminal_size",
+ "unicase",
+ "unicode-width 0.2.2",
+]
+
+[[package]]
+name = "clap_derive"
+version = "4.5.55"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "clap_lex"
+version = "0.7.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32"
+
+[[package]]
+name = "colorchoice"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
+
+[[package]]
+name = "convert_case"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "affbf0190ed2caf063e3def54ff444b449371d55c58e513a95ab98eca50adb49"
+dependencies = [
+ "unicode-segmentation",
+]
+
+[[package]]
+name = "core-foundation-sys"
+version = "0.8.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
+
+[[package]]
+name = "cpufeatures"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "crc32fast"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "crossbeam-deque"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51"
+dependencies = [
+ "crossbeam-epoch",
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "crossbeam-epoch"
+version = "0.9.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
+dependencies = [
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "crossbeam-utils"
+version = "0.8.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
+
+[[package]]
+name = "crypto-common"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
+dependencies = [
+ "generic-array",
+ "typenum",
+]
+
+[[package]]
+name = "darling"
+version = "0.20.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee"
+dependencies = [
+ "darling_core",
+ "darling_macro",
+]
+
+[[package]]
+name = "darling_core"
+version = "0.20.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e"
+dependencies = [
+ "fnv",
+ "ident_case",
+ "proc-macro2",
+ "quote",
+ "strsim",
+ "syn",
+]
+
+[[package]]
+name = "darling_macro"
+version = "0.20.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead"
+dependencies = [
+ "darling_core",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "data-encoding"
+version = "2.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea"
+
+[[package]]
+name = "deranged"
+version = "0.5.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587"
+dependencies = [
+ "powerfmt",
+]
+
+[[package]]
+name = "derive_builder"
+version = "0.20.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947"
+dependencies = [
+ "derive_builder_macro",
+]
+
+[[package]]
+name = "derive_builder_core"
+version = "0.20.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8"
+dependencies = [
+ "darling",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "derive_builder_macro"
+version = "0.20.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c"
+dependencies = [
+ "derive_builder_core",
+ "syn",
+]
+
+[[package]]
+name = "deunicode"
+version = "1.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "abd57806937c9cc163efc8ea3910e00a62e2aeb0b8119f1793a978088f8f6b04"
+
+[[package]]
+name = "digest"
+version = "0.10.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
+dependencies = [
+ "block-buffer",
+ "crypto-common",
+]
+
+[[package]]
+name = "displaydoc"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "either"
+version = "1.15.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
+
+[[package]]
+name = "entities"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b5320ae4c3782150d900b79807611a59a99fc9a1d61d686faafc24b93fc8d7ca"
+
+[[package]]
+name = "env_home"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c7f84e12ccf0a7ddc17a6c41c93326024c42920d7ee630d04950e6926645c0fe"
+
+[[package]]
+name = "equivalent"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
+
+[[package]]
+name = "errno"
+version = "0.3.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
+dependencies = [
+ "libc",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "file-id"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e1fc6a637b6dc58414714eddd9170ff187ecb0933d4c7024d1abbd23a3cc26e9"
+dependencies = [
+ "windows-sys 0.60.2",
+]
+
+[[package]]
+name = "find-msvc-tools"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
+
+[[package]]
+name = "fixedbitset"
+version = "0.5.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99"
+
+[[package]]
+name = "fjadra"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c1671b620ba6e60c11c62b0ea5fec4f8621991e7b1229fa13c010a2cd04e4342"
+
+[[package]]
+name = "flate2"
+version = "1.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
+dependencies = [
+ "crc32fast",
+ "miniz_oxide",
+]
+
+[[package]]
+name = "fnv"
+version = "1.0.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
+
+[[package]]
+name = "foldhash"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
+
+[[package]]
+name = "form_urlencoded"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
+dependencies = [
+ "percent-encoding",
+]
+
+[[package]]
+name = "fsevent-sys"
+version = "4.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "futures-channel"
+version = "0.3.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10"
+dependencies = [
+ "futures-core",
+]
+
+[[package]]
+name = "futures-core"
+version = "0.3.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e"
+
+[[package]]
+name = "futures-macro"
+version = "0.3.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "futures-sink"
+version = "0.3.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7"
+
+[[package]]
+name = "futures-task"
+version = "0.3.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988"
+
+[[package]]
+name = "futures-timer"
+version = "3.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24"
+
+[[package]]
+name = "futures-util"
+version = "0.3.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
+dependencies = [
+ "futures-core",
+ "futures-macro",
+ "futures-sink",
+ "futures-task",
+ "pin-project-lite",
+ "pin-utils",
+ "slab",
+]
+
+[[package]]
+name = "generic-array"
+version = "0.14.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
+dependencies = [
+ "typenum",
+ "version_check",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "wasi",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi",
+ "wasip2",
+]
+
+[[package]]
+name = "gimli"
+version = "0.32.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7"
+
+[[package]]
+name = "glob"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
+
+[[package]]
+name = "globset"
+version = "0.4.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3"
+dependencies = [
+ "aho-corasick",
+ "bstr",
+ "log",
+ "regex-automata 0.4.14",
+ "regex-syntax 0.8.9",
+]
+
+[[package]]
+name = "globwalk"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0bf760ebf69878d9fd8f110c89703d90ce35095324d1f1edcb595c63945ee757"
+dependencies = [
+ "bitflags 2.10.0",
+ "ignore",
+ "walkdir",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.15.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
+dependencies = [
+ "allocator-api2",
+ "equivalent",
+ "foldhash",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.16.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
+
+[[package]]
+name = "heck"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+
+[[package]]
+name = "hex_color"
+version = "3.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d37f101bf4c633f7ca2e4b5e136050314503dd198e78e325ea602c327c484ef0"
+dependencies = [
+ "arrayvec 0.7.6",
+ "rand 0.8.5",
+ "serde",
+]
+
+[[package]]
+name = "http"
+version = "1.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a"
+dependencies = [
+ "bytes",
+ "itoa",
+]
+
+[[package]]
+name = "http-body"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
+dependencies = [
+ "bytes",
+ "http",
+]
+
+[[package]]
+name = "http-body-util"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "http",
+ "http-body",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "http-range-header"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c"
+
+[[package]]
+name = "httparse"
+version = "1.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
+
+[[package]]
+name = "httpdate"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
+
+[[package]]
+name = "humansize"
+version = "2.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6cb51c9a029ddc91b07a787f1d86b53ccfa49b0e86688c946ebe8d3555685dd7"
+dependencies = [
+ "libm",
+]
+
+[[package]]
+name = "humantime"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424"
+
+[[package]]
+name = "hyper"
+version = "1.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11"
+dependencies = [
+ "atomic-waker",
+ "bytes",
+ "futures-channel",
+ "futures-core",
+ "http",
+ "http-body",
+ "httparse",
+ "httpdate",
+ "itoa",
+ "pin-project-lite",
+ "pin-utils",
+ "smallvec",
+ "tokio",
+]
+
+[[package]]
+name = "hyper-util"
+version = "0.1.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
+dependencies = [
+ "bytes",
+ "http",
+ "http-body",
+ "hyper",
+ "pin-project-lite",
+ "tokio",
+ "tower-service",
+]
+
+[[package]]
+name = "iana-time-zone"
+version = "0.1.65"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
+dependencies = [
+ "android_system_properties",
+ "core-foundation-sys",
+ "iana-time-zone-haiku",
+ "js-sys",
+ "log",
+ "wasm-bindgen",
+ "windows-core",
+]
+
+[[package]]
+name = "iana-time-zone-haiku"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
+dependencies = [
+ "cc",
+]
+
+[[package]]
+name = "icu_collections"
+version = "2.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43"
+dependencies = [
+ "displaydoc",
+ "potential_utf",
+ "yoke",
+ "zerofrom",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_locale_core"
+version = "2.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6"
+dependencies = [
+ "displaydoc",
+ "litemap",
+ "tinystr",
+ "writeable",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer"
+version = "2.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599"
+dependencies = [
+ "icu_collections",
+ "icu_normalizer_data",
+ "icu_properties",
+ "icu_provider",
+ "smallvec",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer_data"
+version = "2.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a"
+
+[[package]]
+name = "icu_properties"
+version = "2.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec"
+dependencies = [
+ "icu_collections",
+ "icu_locale_core",
+ "icu_properties_data",
+ "icu_provider",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_properties_data"
+version = "2.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af"
+
+[[package]]
+name = "icu_provider"
+version = "2.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614"
+dependencies = [
+ "displaydoc",
+ "icu_locale_core",
+ "writeable",
+ "yoke",
+ "zerofrom",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "ident_case"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
+
+[[package]]
+name = "idna"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
+dependencies = [
+ "idna_adapter",
+ "smallvec",
+ "utf8_iter",
+]
+
+[[package]]
+name = "idna_adapter"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344"
+dependencies = [
+ "icu_normalizer",
+ "icu_properties",
+]
+
+[[package]]
+name = "ignore"
+version = "0.4.25"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3d782a365a015e0f5c04902246139249abf769125006fbe7649e2ee88169b4a"
+dependencies = [
+ "crossbeam-deque",
+ "globset",
+ "log",
+ "memchr",
+ "regex-automata 0.4.14",
+ "same-file",
+ "walkdir",
+ "winapi-util",
+]
+
+[[package]]
+name = "indenter"
+version = "0.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "964de6e86d545b246d84badc0fef527924ace5134f30641c203ef52ba83f58d5"
+
+[[package]]
+name = "indexmap"
+version = "2.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017"
+dependencies = [
+ "equivalent",
+ "hashbrown 0.16.1",
+]
+
+[[package]]
+name = "inkjet"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "56b828db0cd62bd220b32745e71f5cf8818af2e20d8cb499a7e221dd91cb323a"
+dependencies = [
+ "ahash",
+ "anyhow",
+ "cc",
+ "serde",
+ "thiserror 1.0.69",
+ "toml 0.8.23",
+ "tree-sitter",
+ "tree-sitter-highlight",
+ "v_htmlescape",
+]
+
+[[package]]
+name = "inotify"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f37dccff2791ab604f9babef0ba14fbe0be30bd368dc541e2b08d07c8aa908f3"
+dependencies = [
+ "bitflags 2.10.0",
+ "inotify-sys",
+ "libc",
+]
+
+[[package]]
+name = "inotify-sys"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "is_ci"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7655c9839580ee829dfacba1d1278c2b7883e50a277ff7541299489d6bdfdc45"
+
+[[package]]
+name = "is_terminal_polyfill"
+version = "1.70.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
+
+[[package]]
+name = "itertools"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
+dependencies = [
+ "either",
+]
+
+[[package]]
+name = "itoa"
+version = "1.0.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
+
+[[package]]
+name = "jiff"
+version = "0.2.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d89a5b5e10d5a9ad6e5d1f4bd58225f655d6fe9767575a5e8ac5a6fe64e04495"
+dependencies = [
+ "jiff-static",
+ "jiff-tzdb-platform",
+ "log",
+ "portable-atomic",
+ "portable-atomic-util",
+ "serde_core",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "jiff-static"
+version = "0.2.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff7a39c8862fc1369215ccf0a8f12dd4598c7f6484704359f0351bd617034dbf"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "jiff-tzdb"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68971ebff725b9e2ca27a601c5eb38a4c5d64422c4cbab0c535f248087eda5c2"
+
+[[package]]
+name = "jiff-tzdb-platform"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8"
+dependencies = [
+ "jiff-tzdb",
+]
+
+[[package]]
+name = "js-sys"
+version = "0.3.85"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3"
+dependencies = [
+ "once_cell",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "kqueue"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a"
+dependencies = [
+ "kqueue-sys",
+ "libc",
+]
+
+[[package]]
+name = "kqueue-sys"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b"
+dependencies = [
+ "bitflags 1.3.2",
+ "libc",
+]
+
+[[package]]
+name = "lazy_static"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
+
+[[package]]
+name = "libc"
+version = "0.2.181"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "459427e2af2b9c839b132acb702a1c654d95e10f8c326bfc2ad11310e458b1c5"
+
+[[package]]
+name = "libm"
+version = "0.2.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
+
+[[package]]
+name = "libyml"
+version = "0.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3302702afa434ffa30847a83305f0a69d6abd74293b6554c18ec85c7ef30c980"
+dependencies = [
+ "anyhow",
+ "version_check",
+]
+
+[[package]]
+name = "linux-raw-sys"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039"
+
+[[package]]
+name = "litemap"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77"
+
+[[package]]
+name = "lock_api"
+version = "0.4.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
+dependencies = [
+ "scopeguard",
+]
+
+[[package]]
+name = "log"
+version = "0.4.29"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
+
+[[package]]
+name = "matchers"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
+dependencies = [
+ "regex-automata 0.4.14",
+]
+
+[[package]]
+name = "matchit"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
+
+[[package]]
+name = "memchr"
+version = "2.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
+
+[[package]]
+name = "miette"
+version = "7.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7"
+dependencies = [
+ "backtrace",
+ "backtrace-ext",
+ "cfg-if",
+ "miette-derive",
+ "owo-colors",
+ "supports-color",
+ "supports-hyperlinks",
+ "supports-unicode",
+ "terminal_size",
+ "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",
+]
+
+[[package]]
+name = "mime"
+version = "0.3.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
+
+[[package]]
+name = "mime_guess"
+version = "2.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
+dependencies = [
+ "mime",
+ "unicase",
+]
+
+[[package]]
+name = "miniz_oxide"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
+dependencies = [
+ "adler2",
+ "simd-adler32",
+]
+
+[[package]]
+name = "mio"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc"
+dependencies = [
+ "libc",
+ "log",
+ "wasi",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "nom"
+version = "8.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "notify"
+version = "8.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3"
+dependencies = [
+ "bitflags 2.10.0",
+ "fsevent-sys",
+ "inotify",
+ "kqueue",
+ "libc",
+ "log",
+ "mio",
+ "notify-types",
+ "walkdir",
+ "windows-sys 0.60.2",
+]
+
+[[package]]
+name = "notify-debouncer-full"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c02b49179cfebc9932238d04d6079912d26de0379328872846118a0fa0dbb302"
+dependencies = [
+ "file-id",
+ "log",
+ "notify",
+ "notify-types",
+ "walkdir",
+]
+
+[[package]]
+name = "notify-types"
+version = "2.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a"
+dependencies = [
+ "bitflags 2.10.0",
+]
+
+[[package]]
+name = "nu-ansi-term"
+version = "0.50.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "num-conv"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050"
+
+[[package]]
+name = "num-traits"
+version = "0.2.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
+dependencies = [
+ "autocfg",
+]
+
+[[package]]
+name = "num_threads"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "object"
+version = "0.37.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "once_cell"
+version = "1.21.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
+
+[[package]]
+name = "once_cell_polyfill"
+version = "1.70.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
+
+[[package]]
+name = "onig"
+version = "6.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "336b9c63443aceef14bea841b899035ae3abe89b7c486aaf4c5bd8aafedac3f0"
+dependencies = [
+ "bitflags 2.10.0",
+ "libc",
+ "once_cell",
+ "onig_sys",
+]
+
+[[package]]
+name = "onig_sys"
+version = "69.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c7f86c6eef3d6df15f23bcfb6af487cbd2fed4e5581d58d5bf1f5f8b7f6727dc"
+dependencies = [
+ "cc",
+ "pkg-config",
+]
+
+[[package]]
+name = "owo-colors"
+version = "4.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9c6901729fa79e91a0913333229e9ca5dc725089d1c363b2f4b4760709dc4a52"
+
+[[package]]
+name = "parking_lot"
+version = "0.12.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
+dependencies = [
+ "lock_api",
+ "parking_lot_core",
+]
+
+[[package]]
+name = "parking_lot_core"
+version = "0.9.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "redox_syscall",
+ "smallvec",
+ "windows-link",
+]
+
+[[package]]
+name = "parse-zoneinfo"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1f2a05b18d44e2957b88f96ba460715e295bc1d7510468a2f3d3b44535d26c24"
+dependencies = [
+ "regex",
+]
+
+[[package]]
+name = "percent-encoding"
+version = "2.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
+
+[[package]]
+name = "pest"
+version = "2.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662"
+dependencies = [
+ "memchr",
+ "ucd-trie",
+]
+
+[[package]]
+name = "pest_derive"
+version = "2.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77"
+dependencies = [
+ "pest",
+ "pest_generator",
+]
+
+[[package]]
+name = "pest_generator"
+version = "2.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f"
+dependencies = [
+ "pest",
+ "pest_meta",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "pest_meta"
+version = "2.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220"
+dependencies = [
+ "pest",
+ "sha2",
+]
+
+[[package]]
+name = "petgraph"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455"
+dependencies = [
+ "fixedbitset",
+ "hashbrown 0.15.5",
+ "indexmap",
+ "serde",
+ "serde_derive",
+]
+
+[[package]]
+name = "phf"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
+dependencies = [
+ "phf_shared",
+]
+
+[[package]]
+name = "phf_codegen"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a"
+dependencies = [
+ "phf_generator",
+ "phf_shared",
+]
+
+[[package]]
+name = "phf_generator"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
+dependencies = [
+ "phf_shared",
+ "rand 0.8.5",
+]
+
+[[package]]
+name = "phf_shared"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
+dependencies = [
+ "siphasher",
+]
+
+[[package]]
+name = "pin-project-lite"
+version = "0.2.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b"
+
+[[package]]
+name = "pin-utils"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
+
+[[package]]
+name = "pkg-config"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
+
+[[package]]
+name = "portable-atomic"
+version = "1.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
+
+[[package]]
+name = "portable-atomic-util"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7a9db96d7fa8782dd8c15ce32ffe8680bbd1e978a43bf51a34d39483540495f5"
+dependencies = [
+ "portable-atomic",
+]
+
+[[package]]
+name = "potential_utf"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77"
+dependencies = [
+ "zerovec",
+]
+
+[[package]]
+name = "powerfmt"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
+
+[[package]]
+name = "ppv-lite86"
+version = "0.2.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
+dependencies = [
+ "zerocopy",
+]
+
+[[package]]
+name = "pretty"
+version = "0.12.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0d22152487193190344590e4f30e219cf3fe140d9e7a3fdb683d82aa2c5f4156"
+dependencies = [
+ "arrayvec 0.5.2",
+ "typed-arena",
+ "unicode-width 0.2.2",
+]
+
+[[package]]
+name = "proc-macro-crate"
+version = "3.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983"
+dependencies = [
+ "toml_edit 0.23.10+spec-1.0.0",
+]
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.106"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "psm"
+version = "0.1.30"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3852766467df634d74f0b2d7819bf8dc483a0eb2e3b0f50f756f9cfe8b0d18d8"
+dependencies = [
+ "ar_archive_writer",
+ "cc",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "r-efi"
+version = "5.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
+
+[[package]]
+name = "rand"
+version = "0.8.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
+dependencies = [
+ "libc",
+ "rand_chacha 0.3.1",
+ "rand_core 0.6.4",
+]
+
+[[package]]
+name = "rand"
+version = "0.9.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
+dependencies = [
+ "rand_chacha 0.9.0",
+ "rand_core 0.9.5",
+]
+
+[[package]]
+name = "rand_chacha"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
+dependencies = [
+ "ppv-lite86",
+ "rand_core 0.6.4",
+]
+
+[[package]]
+name = "rand_chacha"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
+dependencies = [
+ "ppv-lite86",
+ "rand_core 0.9.5",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
+dependencies = [
+ "getrandom 0.2.17",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
+dependencies = [
+ "getrandom 0.3.4",
+]
+
+[[package]]
+name = "redox_syscall"
+version = "0.5.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
+dependencies = [
+ "bitflags 2.10.0",
+]
+
+[[package]]
+name = "regex"
+version = "1.12.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-automata 0.4.14",
+ "regex-syntax 0.8.9",
+]
+
+[[package]]
+name = "regex-automata"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "59b23e92ee4318893fa3fe3e6fb365258efbfe6ac6ab30f090cdcbb7aa37efa9"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-syntax 0.7.5",
+]
+
+[[package]]
+name = "regex-automata"
+version = "0.4.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-syntax 0.8.9",
+]
+
+[[package]]
+name = "regex-syntax"
+version = "0.7.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dbb5fb1acd8a1a18b3dd5be62d25485eb770e05afb408a9627d14d451bae12da"
+
+[[package]]
+name = "regex-syntax"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c"
+
+[[package]]
+name = "relative-path"
+version = "1.9.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2"
+
+[[package]]
+name = "rstest"
+version = "0.26.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f5a3193c063baaa2a95a33f03035c8a72b83d97a54916055ba22d35ed3839d49"
+dependencies = [
+ "futures-timer",
+ "futures-util",
+ "rstest_macros",
+]
+
+[[package]]
+name = "rstest_macros"
+version = "0.26.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9c845311f0ff7951c5506121a9ad75aec44d083c31583b2ea5a30bcb0b0abba0"
+dependencies = [
+ "cfg-if",
+ "glob",
+ "proc-macro-crate",
+ "proc-macro2",
+ "quote",
+ "regex",
+ "relative-path",
+ "rustc_version",
+ "syn",
+ "unicode-ident",
+]
+
+[[package]]
+name = "rustc-demangle"
+version = "0.1.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d"
+
+[[package]]
+name = "rustc-hash"
+version = "2.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
+
+[[package]]
+name = "rustc_version"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
+dependencies = [
+ "semver",
+]
+
+[[package]]
+name = "rustix"
+version = "1.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34"
+dependencies = [
+ "bitflags 2.10.0",
+ "errno",
+ "libc",
+ "linux-raw-sys",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "rustversion"
+version = "1.0.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
+
+[[package]]
+name = "ryu"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
+
+[[package]]
+name = "sable"
+version = "0.1.0"
+dependencies = [
+ "axum",
+ "camino",
+ "clap",
+ "http-body-util",
+ "humantime",
+ "miette",
+ "notify-debouncer-full",
+ "sable-core",
+ "sable-renderer",
+ "sable-vault",
+ "thiserror 2.0.18",
+ "tokio",
+ "tower-http",
+ "tracing",
+ "tracing-subscriber",
+ "vergen-gitcl",
+]
+
+[[package]]
+name = "sable-bases"
+version = "0.1.0"
+dependencies = [
+ "askama_escape",
+ "camino",
+ "chumsky",
+ "itertools",
+ "jiff",
+ "miette",
+ "rstest",
+ "serde",
+ "serde_yml",
+ "thiserror 2.0.18",
+]
+
+[[package]]
+name = "sable-canvas"
+version = "0.1.0"
+dependencies = [
+ "hex_color",
+ "serde",
+ "serde_json",
+ "thiserror 2.0.18",
+ "url",
+]
+
+[[package]]
+name = "sable-core"
+version = "0.1.0"
+dependencies = [
+ "miette",
+ "serde",
+ "serde_json",
+ "thiserror 2.0.18",
+ "toml 0.9.11+spec-1.1.0",
+]
+
+[[package]]
+name = "sable-frontmatter"
+version = "0.1.0"
+dependencies = [
+ "memchr",
+ "miette",
+ "serde_json",
+ "serde_yml",
+ "thiserror 2.0.18",
+ "toml 0.9.11+spec-1.1.0",
+]
+
+[[package]]
+name = "sable-markdown"
+version = "0.1.0"
+dependencies = [
+ "entities",
+ "heck",
+ "inkjet",
+ "nom",
+ "pretty",
+ "rstest",
+ "slug",
+ "syntect",
+ "unicode_categories",
+]
+
+[[package]]
+name = "sable-renderer"
+version = "0.1.0"
+dependencies = [
+ "data-encoding",
+ "fjadra",
+ "indenter",
+ "itertools",
+ "miette",
+ "rustc-hash",
+ "sable-bases",
+ "sable-canvas",
+ "sable-core",
+ "sable-markdown",
+ "sable-vault",
+ "serde",
+ "serde_json",
+ "slug",
+ "svg",
+ "tera",
+ "thiserror 2.0.18",
+ "tracing",
+ "url-escape",
+]
+
+[[package]]
+name = "sable-vault"
+version = "0.1.0"
+dependencies = [
+ "camino",
+ "convert_case",
+ "jiff",
+ "miette",
+ "nom",
+ "parking_lot",
+ "petgraph",
+ "regex",
+ "sable-frontmatter",
+ "serde",
+ "serde_json",
+ "slug",
+ "thiserror 2.0.18",
+ "tracing",
+ "walkdir",
+ "which",
+]
+
+[[package]]
+name = "same-file"
+version = "1.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
+dependencies = [
+ "winapi-util",
+]
+
+[[package]]
+name = "scopeguard"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
+
+[[package]]
+name = "semver"
+version = "1.0.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2"
+
+[[package]]
+name = "serde"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.149"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
+dependencies = [
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
+[[package]]
+name = "serde_path_to_error"
+version = "0.1.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
+dependencies = [
+ "itoa",
+ "serde",
+ "serde_core",
+]
+
+[[package]]
+name = "serde_spanned"
+version = "0.6.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "serde_spanned"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "serde_urlencoded"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
+dependencies = [
+ "form_urlencoded",
+ "itoa",
+ "ryu",
+ "serde",
+]
+
+[[package]]
+name = "serde_yml"
+version = "0.0.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "59e2dd588bf1597a252c3b920e0143eb99b0f76e4e082f4c92ce34fbc9e71ddd"
+dependencies = [
+ "indexmap",
+ "itoa",
+ "libyml",
+ "memchr",
+ "ryu",
+ "serde",
+ "version_check",
+]
+
+[[package]]
+name = "sha1"
+version = "0.10.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
+dependencies = [
+ "cfg-if",
+ "cpufeatures",
+ "digest",
+]
+
+[[package]]
+name = "sha2"
+version = "0.10.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
+dependencies = [
+ "cfg-if",
+ "cpufeatures",
+ "digest",
+]
+
+[[package]]
+name = "sharded-slab"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
+dependencies = [
+ "lazy_static",
+]
+
+[[package]]
+name = "shlex"
+version = "1.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
+
+[[package]]
+name = "signal-hook-registry"
+version = "1.4.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
+dependencies = [
+ "errno",
+ "libc",
+]
+
+[[package]]
+name = "simd-adler32"
+version = "0.3.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2"
+
+[[package]]
+name = "siphasher"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e"
+
+[[package]]
+name = "slab"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
+
+[[package]]
+name = "slug"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "882a80f72ee45de3cc9a5afeb2da0331d58df69e4e7d8eeb5d3c7784ae67e724"
+dependencies = [
+ "deunicode",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "smallvec"
+version = "1.15.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
+
+[[package]]
+name = "socket2"
+version = "0.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0"
+dependencies = [
+ "libc",
+ "windows-sys 0.60.2",
+]
+
+[[package]]
+name = "stable_deref_trait"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
+
+[[package]]
+name = "stacker"
+version = "0.1.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "08d74a23609d509411d10e2176dc2a4346e3b4aea2e7b1869f19fdedbc71c013"
+dependencies = [
+ "cc",
+ "cfg-if",
+ "libc",
+ "psm",
+ "windows-sys 0.59.0",
+]
+
+[[package]]
+name = "strsim"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
+
+[[package]]
+name = "supports-color"
+version = "3.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c64fc7232dd8d2e4ac5ce4ef302b1d81e0b80d055b9d77c7c4f51f6aa4c867d6"
+dependencies = [
+ "is_ci",
+]
+
+[[package]]
+name = "supports-hyperlinks"
+version = "3.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e396b6523b11ccb83120b115a0b7366de372751aa6edf19844dfb13a6af97e91"
+
+[[package]]
+name = "supports-unicode"
+version = "3.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b7401a30af6cb5818bb64852270bb722533397edcfc7344954a38f420819ece2"
+
+[[package]]
+name = "svg"
+version = "0.18.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94afda9cd163c04f6bee8b4bf2501c91548deae308373c436f36aeff3cf3c4a3"
+
+[[package]]
+name = "syn"
+version = "2.0.114"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "sync_wrapper"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
+
+[[package]]
+name = "synstructure"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "syntect"
+version = "5.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "656b45c05d95a5704399aeef6bd0ddec7b2b3531b7c9e900abbf7c4d2190c925"
+dependencies = [
+ "bincode",
+ "flate2",
+ "fnv",
+ "once_cell",
+ "onig",
+ "regex-syntax 0.8.9",
+ "serde",
+ "serde_derive",
+ "thiserror 2.0.18",
+ "walkdir",
+]
+
+[[package]]
+name = "tera"
+version = "1.20.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e8004bca281f2d32df3bacd59bc67b312cb4c70cea46cbd79dbe8ac5ed206722"
+dependencies = [
+ "chrono",
+ "chrono-tz",
+ "globwalk",
+ "humansize",
+ "lazy_static",
+ "percent-encoding",
+ "pest",
+ "pest_derive",
+ "rand 0.8.5",
+ "regex",
+ "serde",
+ "serde_json",
+ "slug",
+ "unicode-segmentation",
+]
+
+[[package]]
+name = "terminal_size"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "60b8cb979cb11c32ce1603f8137b22262a9d131aaa5c37b5678025f22b8becd0"
+dependencies = [
+ "rustix",
+ "windows-sys 0.60.2",
+]
+
+[[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.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
+dependencies = [
+ "thiserror-impl 2.0.18",
+]
+
+[[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",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "2.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "thread_local"
+version = "1.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "time"
+version = "0.3.47"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
+dependencies = [
+ "deranged",
+ "itoa",
+ "libc",
+ "num-conv",
+ "num_threads",
+ "powerfmt",
+ "serde_core",
+ "time-core",
+ "time-macros",
+]
+
+[[package]]
+name = "time-core"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca"
+
+[[package]]
+name = "time-macros"
+version = "0.2.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215"
+dependencies = [
+ "num-conv",
+ "time-core",
+]
+
+[[package]]
+name = "tinystr"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869"
+dependencies = [
+ "displaydoc",
+ "zerovec",
+]
+
+[[package]]
+name = "tokio"
+version = "1.49.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86"
+dependencies = [
+ "bytes",
+ "libc",
+ "mio",
+ "pin-project-lite",
+ "signal-hook-registry",
+ "socket2",
+ "tokio-macros",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "tokio-macros"
+version = "2.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "tokio-tungstenite"
+version = "0.28.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857"
+dependencies = [
+ "futures-util",
+ "log",
+ "tokio",
+ "tungstenite",
+]
+
+[[package]]
+name = "tokio-util"
+version = "0.7.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "futures-sink",
+ "pin-project-lite",
+ "tokio",
+]
+
+[[package]]
+name = "toml"
+version = "0.8.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
+dependencies = [
+ "serde",
+ "serde_spanned 0.6.9",
+ "toml_datetime 0.6.11",
+ "toml_edit 0.22.27",
+]
+
+[[package]]
+name = "toml"
+version = "0.9.11+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f3afc9a848309fe1aaffaed6e1546a7a14de1f935dc9d89d32afd9a44bab7c46"
+dependencies = [
+ "indexmap",
+ "serde_core",
+ "serde_spanned 1.0.4",
+ "toml_datetime 0.7.5+spec-1.1.0",
+ "toml_parser",
+ "toml_writer",
+ "winnow",
+]
+
+[[package]]
+name = "toml_datetime"
+version = "0.6.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "toml_datetime"
+version = "0.7.5+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "toml_edit"
+version = "0.22.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
+dependencies = [
+ "indexmap",
+ "serde",
+ "serde_spanned 0.6.9",
+ "toml_datetime 0.6.11",
+ "toml_write",
+ "winnow",
+]
+
+[[package]]
+name = "toml_edit"
+version = "0.23.10+spec-1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269"
+dependencies = [
+ "indexmap",
+ "toml_datetime 0.7.5+spec-1.1.0",
+ "toml_parser",
+ "winnow",
+]
+
+[[package]]
+name = "toml_parser"
+version = "1.0.6+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a3198b4b0a8e11f09dd03e133c0280504d0801269e9afa46362ffde1cbeebf44"
+dependencies = [
+ "winnow",
+]
+
+[[package]]
+name = "toml_write"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
+
+[[package]]
+name = "toml_writer"
+version = "1.0.6+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607"
+
+[[package]]
+name = "tower"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
+dependencies = [
+ "futures-core",
+ "futures-util",
+ "pin-project-lite",
+ "sync_wrapper",
+ "tokio",
+ "tower-layer",
+ "tower-service",
+ "tracing",
+]
+
+[[package]]
+name = "tower-http"
+version = "0.6.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8"
+dependencies = [
+ "bitflags 2.10.0",
+ "bytes",
+ "futures-core",
+ "futures-util",
+ "http",
+ "http-body",
+ "http-body-util",
+ "http-range-header",
+ "httpdate",
+ "mime",
+ "mime_guess",
+ "percent-encoding",
+ "pin-project-lite",
+ "tokio",
+ "tokio-util",
+ "tower-layer",
+ "tower-service",
+ "tracing",
+]
+
+[[package]]
+name = "tower-layer"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
+
+[[package]]
+name = "tower-service"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
+
+[[package]]
+name = "tracing"
+version = "0.1.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
+dependencies = [
+ "log",
+ "pin-project-lite",
+ "tracing-attributes",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-attributes"
+version = "0.1.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "tracing-core"
+version = "0.1.36"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
+dependencies = [
+ "once_cell",
+ "valuable",
+]
+
+[[package]]
+name = "tracing-log"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
+dependencies = [
+ "log",
+ "once_cell",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-subscriber"
+version = "0.3.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e"
+dependencies = [
+ "matchers",
+ "nu-ansi-term",
+ "once_cell",
+ "regex-automata 0.4.14",
+ "sharded-slab",
+ "smallvec",
+ "thread_local",
+ "tracing",
+ "tracing-core",
+ "tracing-log",
+]
+
+[[package]]
+name = "tree-sitter"
+version = "0.23.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0203df02a3b6dd63575cc1d6e609edc2181c9a11867a271b25cfd2abff3ec5ca"
+dependencies = [
+ "cc",
+ "regex",
+ "regex-syntax 0.8.9",
+ "tree-sitter-language",
+]
+
+[[package]]
+name = "tree-sitter-highlight"
+version = "0.23.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "380a7706376fa6c52ba7bf71d1e7a93856ee8ab08a7680631dfa664fdd237d66"
+dependencies = [
+ "lazy_static",
+ "regex",
+ "thiserror 1.0.69",
+ "tree-sitter",
+]
+
+[[package]]
+name = "tree-sitter-language"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "009994f150cc0cd50ff54917d5bc8bffe8cad10ca10d81c34da2ec421ae61782"
+
+[[package]]
+name = "tungstenite"
+version = "0.28.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442"
+dependencies = [
+ "bytes",
+ "data-encoding",
+ "http",
+ "httparse",
+ "log",
+ "rand 0.9.2",
+ "sha1",
+ "thiserror 2.0.18",
+ "utf-8",
+]
+
+[[package]]
+name = "typed-arena"
+version = "2.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6af6ae20167a9ece4bcb41af5b80f8a1f1df981f6391189ce00fd257af04126a"
+
+[[package]]
+name = "typenum"
+version = "1.19.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
+
+[[package]]
+name = "ucd-trie"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971"
+
+[[package]]
+name = "unicase"
+version = "2.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "537dd038a89878be9b64dd4bd1b260315c1bb94f4d784956b81e27a088d9a09e"
+
+[[package]]
+name = "unicode-linebreak"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f"
+
+[[package]]
+name = "unicode-segmentation"
+version = "1.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
+
+[[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_categories"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e"
+
+[[package]]
+name = "url"
+version = "2.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
+dependencies = [
+ "form_urlencoded",
+ "idna",
+ "percent-encoding",
+ "serde",
+ "serde_derive",
+]
+
+[[package]]
+name = "url-escape"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "44e0ce4d1246d075ca5abec4b41d33e87a6054d08e2366b63205665e950db218"
+dependencies = [
+ "percent-encoding",
+]
+
+[[package]]
+name = "utf-8"
+version = "0.7.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
+
+[[package]]
+name = "utf8_iter"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
+
+[[package]]
+name = "utf8parse"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
+
+[[package]]
+name = "v_htmlescape"
+version = "0.15.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4e8257fbc510f0a46eb602c10215901938b5c2a7d5e70fc11483b1d3c9b5b18c"
+
+[[package]]
+name = "valuable"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
+
+[[package]]
+name = "vergen"
+version = "9.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b849a1f6d8639e8de261e81ee0fc881e3e3620db1af9f2e0da015d4382ceaf75"
+dependencies = [
+ "anyhow",
+ "derive_builder",
+ "rustversion",
+ "vergen-lib",
+]
+
+[[package]]
+name = "vergen-gitcl"
+version = "9.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77ff3b5300a085d6bcd8fc96a507f706a28ae3814693236c9b409db71a1d15b9"
+dependencies = [
+ "anyhow",
+ "derive_builder",
+ "rustversion",
+ "time",
+ "vergen",
+ "vergen-lib",
+]
+
+[[package]]
+name = "vergen-lib"
+version = "9.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b34a29ba7e9c59e62f229ae1932fb1b8fb8a6fdcc99215a641913f5f5a59a569"
+dependencies = [
+ "anyhow",
+ "derive_builder",
+ "rustversion",
+]
+
+[[package]]
+name = "version_check"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
+
+[[package]]
+name = "walkdir"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
+dependencies = [
+ "same-file",
+ "winapi-util",
+]
+
+[[package]]
+name = "wasi"
+version = "0.11.1+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
+
+[[package]]
+name = "wasip2"
+version = "1.0.2+wasi-0.2.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5"
+dependencies = [
+ "wit-bindgen",
+]
+
+[[package]]
+name = "wasm-bindgen"
+version = "0.2.108"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566"
+dependencies = [
+ "cfg-if",
+ "once_cell",
+ "rustversion",
+ "wasm-bindgen-macro",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-macro"
+version = "0.2.108"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608"
+dependencies = [
+ "quote",
+ "wasm-bindgen-macro-support",
+]
+
+[[package]]
+name = "wasm-bindgen-macro-support"
+version = "0.2.108"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55"
+dependencies = [
+ "bumpalo",
+ "proc-macro2",
+ "quote",
+ "syn",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-shared"
+version = "0.2.108"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "which"
+version = "8.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3fabb953106c3c8eea8306e4393700d7657561cb43122571b172bbfb7c7ba1d"
+dependencies = [
+ "env_home",
+ "rustix",
+ "winsafe",
+]
+
+[[package]]
+name = "winapi-util"
+version = "0.1.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "windows-core"
+version = "0.62.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
+dependencies = [
+ "windows-implement",
+ "windows-interface",
+ "windows-link",
+ "windows-result",
+ "windows-strings",
+]
+
+[[package]]
+name = "windows-implement"
+version = "0.60.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "windows-interface"
+version = "0.59.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[package]]
+name = "windows-result"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-strings"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.59.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
+dependencies = [
+ "windows-targets 0.52.6",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.60.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
+dependencies = [
+ "windows-targets 0.53.5",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.61.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
+dependencies = [
+ "windows_aarch64_gnullvm 0.52.6",
+ "windows_aarch64_msvc 0.52.6",
+ "windows_i686_gnu 0.52.6",
+ "windows_i686_gnullvm 0.52.6",
+ "windows_i686_msvc 0.52.6",
+ "windows_x86_64_gnu 0.52.6",
+ "windows_x86_64_gnullvm 0.52.6",
+ "windows_x86_64_msvc 0.52.6",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.53.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
+dependencies = [
+ "windows-link",
+ "windows_aarch64_gnullvm 0.53.1",
+ "windows_aarch64_msvc 0.53.1",
+ "windows_i686_gnu 0.53.1",
+ "windows_i686_gnullvm 0.53.1",
+ "windows_i686_msvc 0.53.1",
+ "windows_x86_64_gnu 0.53.1",
+ "windows_x86_64_gnullvm 0.53.1",
+ "windows_x86_64_msvc 0.53.1",
+]
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
+
+[[package]]
+name = "windows_i686_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
+
+[[package]]
+name = "windows_i686_gnullvm"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
+
+[[package]]
+name = "winnow"
+version = "0.7.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "winsafe"
+version = "0.0.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904"
+
+[[package]]
+name = "wit-bindgen"
+version = "0.51.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
+
+[[package]]
+name = "writeable"
+version = "0.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9"
+
+[[package]]
+name = "yoke"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954"
+dependencies = [
+ "stable_deref_trait",
+ "yoke-derive",
+ "zerofrom",
+]
+
+[[package]]
+name = "yoke-derive"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+ "synstructure",
+]
+
+[[package]]
+name = "zerocopy"
+version = "0.8.39"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a"
+dependencies = [
+ "zerocopy-derive",
+]
+
+[[package]]
+name = "zerocopy-derive"
+version = "0.8.39"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "zerofrom"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5"
+dependencies = [
+ "zerofrom-derive",
+]
+
+[[package]]
+name = "zerofrom-derive"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+ "synstructure",
+]
+
+[[package]]
+name = "zerotrie"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851"
+dependencies = [
+ "displaydoc",
+ "yoke",
+ "zerofrom",
+]
+
+[[package]]
+name = "zerovec"
+version = "0.11.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002"
+dependencies = [
+ "yoke",
+ "zerofrom",
+ "zerovec-derive",
+]
+
+[[package]]
+name = "zerovec-derive"
+version = "0.11.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "zmij"
+version = "1.0.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4de98dfa5d5b7fef4ee834d0073d560c9ca7b6c46a71d058c48db7960f8cfaf7"
diff --git a/Cargo.toml b/Cargo.toml
new file mode 100644
index 0000000..a5f6696
+[workspace]
+resolver = "2"
+members = [
+ "sable",
+ "sable-bases",
+ "sable-canvas",
+ "sable-core",
+ "sable-frontmatter",
+ "sable-markdown",
+ "sable-renderer",
+ "sable-vault",
+]
+
+[workspace.dependencies]
+askama_escape = "0.15.1"
+axum = { version = "0.8.4", features = ["ws"] }
+axum-extra = { version = "0.10.1", features = ["typed-header"] }
+camino = { version = "1.1.10", features = ["serde1"] }
+caseless = "0.2.1"
+chumsky = { version = "0.12.0", features = ["pratt"] }
+clap = { version = "4.5.39", features = [
+ "derive",
+ "string",
+ "unicode",
+ "wrap_help",
+] }
+comrak = { version = "0.39.0", default-features = false, features = [
+ "bon",
+ "syntect",
+] }
+convert_case = "0.11.0"
+data-encoding = "2.9.0"
+derive_more = { version = "2.0.1", features = ["deref", "deref_mut"] }
+educe = "0.6.0"
+emojis = "0.6.2"
+entities = "1.0.1"
+fjadra = "0.2.1"
+futures = "0.3.31"
+glob = "0.3.2"
+heck = "0.5.0"
+http-body-util = "0.1.3"
+humantime = "2.2.0"
+indenter = { version = "0.3.3", features = ["std"] }
+inkjet = "0.11.1"
+itertools = "0.14.0"
+jiff = { version = "0.2.15", features = ["serde"] }
+jsoncanvas = "0.1.6" # TODO: fork to clean up dependency situation
+linkify = "0.10.0"
+markdown-it-footnotes = "0.1.0"
+memchr = "2.7.5"
+miette = { version = "7.6.0", features = ["fancy"] }
+nom = "8.0.0"
+notify-debouncer-full = "0.7.0"
+owo-colors = "4.2.3"
+parking_lot = "0.12.4"
+petgraph = { version = "0.8.2", features = ["serde-1"] }
+pretty = "0.12.4"
+rayon = "1.10.0"
+regex = "1.11.1"
+rquickjs = { version = "0.11.0", features = ["macro"] }
+rstest = "0.26.1"
+rustc-hash = "2.1.1"
+serde = { version = "1.0.219", features = ["derive"] }
+serde_json = "1.0.140"
+serde_toml = { package = "toml", version = "0.9.4" }
+serde_yaml = { package = "serde_yml", version = "0.0.12" }
+slug = "0.1.6"
+svg = "0.18.0"
+syntect = { version = "5.0", default-features = false, features = [
+ "default-themes",
+ "default-syntaxes",
+ "html",
+ "regex-onig",
+] }
+tempfile = "3.20.0"
+tera = "1.20.0"
+thiserror = "2.0.12"
+tokio = { version = "1.45.1", features = [
+ "macros",
+ "rt-multi-thread",
+ "signal",
+] }
+tokio-tungstenite = "0.26.2"
+tower = "0.5.2"
+tower-http = { version = "0.6.6", features = ["fs", "timeout"] }
+tracing = "0.1.41"
+tracing-subscriber = { version = "0.3.19", features = ["env-filter"] }
+typed-arena = "2.0.2"
+unicode_categories = "0.1.1"
+unicode-segmentation = "1.12.0"
+url-escape = "0.1.1"
+vergen-gitcl = "9.1.0"
+walkdir = "2.5.0"
+which = "8.0.0"
+
+[profile.release]
+debug = "full"
+lto = true
+codegen-units = 1
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..9ed6d41
+# `sable`
+
+`sable` is a obsidian renderer that powers my website.
+
+## Feature Support
+
+ - Markdown
+ - [ ] Escaping
+ - [X] Frontmatter
+ - [X] YAML
+ - [X] TOML
+ - [X] JSON
+ - [X] Syntax Highlighting
+ - [ ] Github Flavored Markdown
+ - [X] Autolinks
+ - [X] Tables
+ - [X] Task List Items
+ - [X] Strikethrough
+ - [ ] Obsidian Flavored Markdown
+ - [X] Callouts
+ - [ ] File Includes
+ - [X] Tags
+ - [X] Wikilinks
+
+ - Obsidian Vault
+ - [ ] Base
+ - [ ] Canvas
+ - [ ] Graph
+ - [X] Note
+
+ - Sable
+ - [ ] Build time assets (eg: running Tailwind)
+ - [ ] Custom data loading
+ - [ ] Dev Server
+ - [ ] Support HTML notes
+
+## Configuration
+
+`sable` is somewhat configurable with its config file and command line arguments.
+
+Config is stored is in the `sable.toml` file, and only some settings can be
+overwritten by command line arguments.
+
+```typescript
+interface Config {
+ // CLI: --dist
+ dist: string = `${CWD}/dist`;
+ // CLI: --static
+ static: string = `${CWD}/static`;
+ // CLI: --templates
+ templates: string = `${CWD}/templates`;
+ // CLI: --src
+ vault: string = `${CWD}/content`;
+
+ // The port the dev server will run on
+ // CLI: [serve] --port
+ port: number = 3000;
+
+ // The default template a Note will be rendered with if not provided a layout
+ // Defaults to a 'hidden' internal template
+ default_template: string;
+
+ // Custom data that will be available to a template
+ data: ConfigData;
+}
+
+interface ConfigData {
+ [key: string]: any;
+}
+```
+
+## Templates
+
+`sable` uses [Tera](https://keats.github.io/tera/) for templating, see its documentation for more information.
+
+Which template is used to render a note can be changed by setting the
+`template` frontmatter variable, otherwise it uses an included 'default'
+template.
+
+```typescript
+const meta: Meta;
+
+// `meta` is meta information about `sable` itself
+interface Meta {
+ package_name: string;
+ package_version: string;
+
+ git_dirty: boolean;
+ git_hash: string;
+}
+
+const data: Data;
+
+// `data` is any data passed in from the config (if it exists)
+interface Data {
+ [key: string]: any;
+}
+
+const note: Note;
+
+// An Obsidian note
+interface Note {
+ path: NotePath;
+
+ name: string;
+ title: string;
+
+ metadata: NoteMetadata;
+ properties: NoteProperties;
+
+ toc: NoteHeading[];
+
+ contents: string;
+}
+
+interface NotePath {
+ vault: VaultPath;
+
+ full: string;
+ relative: string;
+
+ slug: string;
+}
+
+type VaultPath = string;
+
+// Contains a Note's file system metadata
+interface NoteMetadata {
+ created: string;
+ modified: string;
+
+ git_created: string | null;
+ git_modified: string | null;
+}
+
+// This is actually the note's frontmatter
+// Its called properties as `sable` supports YAML, TOML, and JSON frontmatter
+interface NoteProperties {
+ [key: string]: any;
+}
+
+interface NoteHeading {
+ level: number;
+ id: string;
+ title: string;
+ children: NoteHeading[];
+}
+
+// Renders a string to Markdown
+function markdown(in: string): string;
+```
diff --git a/sable-bases/Cargo.toml b/sable-bases/Cargo.toml
new file mode 100644
index 0000000..c3854af
+[package]
+name = "sable-bases"
+version = "0.1.0"
+edition = "2024"
+
+workspace = ".."
+
+[dependencies]
+askama_escape.workspace = true
+camino.workspace = true
+chumsky.workspace = true
+itertools.workspace = true
+jiff.workspace = true
+miette.workspace = true
+# rquickjs.workspace = true
+serde.workspace = true
+serde_yaml.workspace = true
+thiserror.workspace = true
+
+[dev-dependencies]
+rstest.workspace = true
diff --git a/sable-bases/README.md b/sable-bases/README.md
new file mode 100644
index 0000000..430d758
+# sable-bases
diff --git a/sable-bases/src/document.rs b/sable-bases/src/document.rs
new file mode 100644
index 0000000..13b161e
+use std::collections::HashMap;
+
+#[derive(Debug, serde::Deserialize)]
+pub struct Base {
+ pub forumlas: Option<HashMap<String, String>>,
+ pub properties: Option<HashMap<String, Property>>,
+ pub filters: Option<Filter>,
+ pub views: Vec<View>,
+}
+
+#[derive(Debug, serde::Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct Property {
+ pub display_name: Option<String>,
+}
+
+#[derive(Debug, serde::Deserialize)]
+#[serde(untagged)]
+pub enum Filter {
+ And(AndFilter),
+ Not(NotFilter),
+ Or(OrFilter),
+ Expr(String),
+}
+
+#[derive(Debug, serde::Deserialize)]
+pub struct AndFilter {
+ pub and: Vec<Filter>,
+}
+
+#[derive(Debug, serde::Deserialize)]
+pub struct NotFilter {
+ pub not: Vec<Filter>,
+}
+
+#[derive(Debug, serde::Deserialize)]
+pub struct OrFilter {
+ pub or: Vec<Filter>,
+}
+
+#[derive(Debug, serde::Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub enum ViewType {
+ Table,
+ Cards,
+}
+
+#[derive(Debug, serde::Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct View {
+ #[serde(rename = "type")]
+ pub typ: ViewType,
+ pub name: String,
+ pub filters: Option<Filter>,
+ pub order: Option<Vec<String>>,
+ pub limit: Option<usize>,
+ pub card_size: Option<usize>,
+}
+
+#[cfg(test)]
+mod test {
+ use super::*;
+
+ #[test]
+ #[ignore]
+ fn test() {
+ static BASE: &str = r#"properties:
+ note.title:
+ displayName: Title
+ note.author:
+ displayName: Author
+ note.url:
+ displayName: URL
+ note.description:
+ displayName: Description
+views:
+ - type: cards
+ name: Cards
+ filters:
+ and:
+ - note["link-data"] == "fanfiction"
+ - favorite == true
+ - not:
+ - favorite == false
+ order:
+ - title
+ - author
+ - url
+ - description
+ limit: 10
+ cardSize: 800
+"#;
+
+ panic!("{:#?}", serde_yaml::from_str::<Base>(BASE));
+ }
+}
diff --git a/sable-bases/src/engine.rs b/sable-bases/src/engine.rs
new file mode 100644
index 0000000..906f6ec
+use chumsky::{Parser as _, input::Input as _};
+
+#[derive(Debug, thiserror::Error)]
+pub enum EvalError {}
+
+pub struct Engine {
+ // runtime: rquickjs::Context,
+}
+
+impl Engine {
+ pub fn new() {}
+
+ pub fn eval(&self, filter: &str) -> Result<(), EvalError> {
+ let tokens = crate::filter::parser::lexer().parse(filter).unwrap();
+ let expr = crate::filter::parser::parser()
+ .parse(tokens[..].split_spanned((0..filter.len()).into()))
+ .unwrap();
+ let js = crate::filter::convert::ast_to_js(&expr.inner);
+
+ // self.runtime.with(|ctx| {});
+
+ Ok(())
+ }
+}
diff --git a/sable-bases/src/eval.rs b/sable-bases/src/eval.rs
new file mode 100644
index 0000000..b19af7a
+#![allow(
+ clippy::todo,
+ clippy::cast_precision_loss,
+ clippy::unnecessary_cast,
+ trivial_casts
+)]
+
+use std::{borrow::Cow, collections::BTreeMap};
+
+use camino::Utf8Path;
+use chumsky::span::SimpleSpan;
+use jiff::{SignedDuration, civil::DateTime};
+
+use crate::filter::ast::{BinOp, Expr, UnOp};
+
+macro_rules! bast {
+ ($name:ident: f64 ) => {
+ if $name { 1.0 } else { 0.0 }
+ };
+ ($name:ident: i64 ) => {
+ if $name { 1 } else { 0 }
+ };
+}
+
+#[derive(Debug, PartialEq)]
+struct TypeError {
+ kind: ErrorKind,
+ pos: SimpleSpan,
+}
+
+#[derive(Debug, PartialEq)]
+enum ErrorKind {}
+
+#[derive(Debug, PartialEq)]
+struct Array<'r> {
+ scope: Vec<ScopeEntry<'r>>,
+}
+
+impl<'r> Array<'r> {
+ fn push<E: Into<ScopeEntry<'r>>>(&mut self, entry: E) {
+ self.scope.push(entry.into());
+ }
+}
+
+impl<'r> From<Array<'r>> for ScopeEntry<'r> {
+ fn from(value: Array<'r>) -> Self {
+ Self::Array(value)
+ }
+}
+
+#[derive(Debug, PartialEq)]
+struct Object<'r> {
+ scope: BTreeMap<&'r str, ScopeEntry<'r>>,
+}
+
+impl<'r> Object<'r> {
+ fn insert<E: Into<ScopeEntry<'r>>>(&mut self, ident: &'r str, entry: E) {
+ self.scope.insert(ident, entry.into());
+ }
+}
+
+impl<'r> From<Object<'r>> for ScopeEntry<'r> {
+ fn from(value: Object<'r>) -> Self {
+ Self::Object(value)
+ }
+}
+
+type Date = DateTime;
+type Duration = SignedDuration;
+
+#[derive(Debug, PartialEq)]
+struct File<'r> {
+ path: &'r Utf8Path,
+ creation_time: DateTime,
+ mondified_time: DateTime,
+}
+
+#[derive(Debug, PartialEq)]
+enum Value<'r, 'vm> {
+ Null,
+ Bool(bool),
+ Integer(i64),
+ Decimal(f64),
+ String(Cow<'r, str>),
+ Array(&'vm Array<'r>),
+ Object(&'vm Object<'r>),
+ Date(Date),
+ Duration(Duration),
+ Link(),
+ File(),
+}
+
+impl<'r, 'vm> Value<'r, 'vm> {
+ #[must_use]
+ const fn is_bool(&self) -> bool {
+ matches!(self, Self::Bool(..))
+ }
+
+ #[must_use]
+ const fn is_integer(&self) -> bool {
+ matches!(self, Self::Integer(..))
+ }
+
+ #[must_use]
+ const fn is_decimal(&self) -> bool {
+ matches!(self, Self::Decimal(..))
+ }
+
+ fn as_string(&self) -> Result<Cow<'r, str>, TypeError> {
+ if let Value::String(str) = self {
+ return Ok(str.clone());
+ }
+
+ todo!()
+ }
+
+ fn into_bool(self) -> Result<Self, TypeError> {
+ match self {
+ Value::Null => todo!(),
+ Value::Bool(value) => Ok(Value::Bool(value)),
+ Value::Integer(value) => Ok(Value::Bool(value != 0)),
+ Value::Decimal(value) => Ok(Value::Bool(value != 0.0)),
+ _ => todo!(),
+ }
+ }
+
+ fn into_integer(self) -> Result<Self, TypeError> {
+ match self {
+ Value::Null => todo!(),
+ Value::Bool(value) => Ok(Value::Integer(bast!(value: i64))),
+ Value::Integer(value) => Ok(Value::Integer(value)),
+ Value::Decimal(value) => Ok(Value::Integer(value.round() as i64)),
+ _ => todo!(),
+ }
+ }
+
+ fn into_decimal(self) -> Result<Self, TypeError> {
+ match self {
+ Value::Null => todo!(),
+ Value::Bool(value) => Ok(Value::Decimal(bast!(value: f64))),
+ Value::Integer(value) => Ok(Value::Decimal(value as f64)),
+ Value::Decimal(value) => Ok(Value::Decimal(value)),
+ _ => todo!(),
+ }
+ }
+
+ fn into_string(self) -> Result<Self, TypeError> {
+ match self {
+ Value::Null => todo!(),
+ Value::Bool(value) => Ok(Value::Bool(value)),
+ Value::Integer(value) => Ok(Value::String(Cow::from(format!("{value}")))),
+ Value::Decimal(value) => Ok(Value::String(Cow::from(format!("{value}")))),
+ Value::String(value) => Ok(Value::String(value)),
+ _ => todo!(),
+ }
+ }
+}
+
+#[derive(Debug, PartialEq)]
+enum ScopeEntry<'r> {
+ Bool(bool),
+ Integer(i64),
+ Decimal(f64),
+ String(&'r str),
+ Array(Array<'r>),
+ Object(Object<'r>),
+ Function(),
+}
+
+struct Vm<'r> {
+ scope: BTreeMap<&'r str, ScopeEntry<'r>>,
+ fallback: Option<&'r str>,
+}
+
+impl<'r> Vm<'r> {
+ const fn new() -> Self {
+ Self {
+ scope: BTreeMap::new(),
+ fallback: None,
+ }
+ }
+
+ fn add_scope<E: Into<ScopeEntry<'r>>>(&mut self, ident: &'r str, entry: E) {
+ self.scope.insert(ident, entry.into());
+ }
+
+ const fn set_fallback(&mut self, ident: &'r str) {
+ self.fallback = Some(ident);
+ }
+
+ fn eval<'vm, 'src: 'r>(&'vm self, expr: &Expr<'src>) -> Result<Value<'r, 'vm>, TypeError> {
+ match expr {
+ Expr::Ident(ident) => {
+ let entry = match (self.scope.get(ident.0.as_ref()), self.fallback.as_ref()) {
+ (Some(entry), _) => Some(entry),
+ (None, Some(fallback)) => self.scope.get(fallback),
+ (None, None) => return Ok(Value::Null),
+ };
+
+ if let Some(entry) = entry {
+ return match entry {
+ ScopeEntry::Bool(value) => Ok(Value::Bool(*value)),
+ ScopeEntry::Integer(value) => Ok(Value::Integer(*value)),
+ ScopeEntry::Decimal(value) => Ok(Value::Decimal(*value)),
+ ScopeEntry::String(value) => Ok(Value::String(Cow::from(*value))),
+ ScopeEntry::Array(value) => Ok(Value::Array(value)),
+ ScopeEntry::Object(value) => Ok(Value::Object(value)),
+ ScopeEntry::Function() => todo!(),
+ };
+ }
+
+ Ok(Value::Null)
+ }
+ Expr::Bool(value) => Ok(Value::Bool(*value)),
+ Expr::Integer(value) => Ok(Value::Integer(*value)),
+ Expr::Decimal(value) => Ok(Value::Decimal(*value)),
+ Expr::String(value) => Ok(Value::String(value.clone())),
+ Expr::Function(ident, params) => {
+ let ident = self.eval(&ident.inner)?;
+ let params = params
+ .inner
+ .iter()
+ .map(|param| self.eval(¶m.inner))
+ .collect::<Result<Vec<_>, _>>()?;
+
+ todo!()
+ }
+ Expr::Method(object, ident, params) => {
+ let object = self.eval(&object.inner)?;
+ let params = params
+ .inner
+ .iter()
+ .map(|param| self.eval(¶m.inner))
+ .collect::<Result<Vec<_>, _>>()?;
+
+ match object {
+ Value::Array(object) => todo!(),
+ Value::Object(object) => todo!(),
+ _ => todo!(),
+ }
+ }
+ Expr::Member(object, ident) => {
+ let object = self.eval(&object.inner)?;
+
+ match object {
+ Value::Array(object) => todo!(),
+ Value::Object(object) => todo!(),
+ _ => todo!(),
+ }
+ }
+ Expr::Index(ident, index) => {
+ let ident = self.eval(&ident.inner)?;
+
+ match ident {
+ Value::Array(object) => todo!(),
+ Value::Object(object) => todo!(),
+ _ => todo!(),
+ }
+ }
+ // TODO: this is the worst way to do this, use the cast methods on Value to clean this up
+ Expr::BinOp(op, lhs, rhs) => {
+ let lhs = self.eval(&lhs.inner)?;
+ let rhs = self.eval(&rhs.inner)?;
+
+ match op.inner {
+ BinOp::Add => match lhs {
+ Value::Bool(lhs) => match rhs {
+ Value::Bool(rhs) => {
+ Ok(Value::Integer(bast!(lhs: i64) + bast!(rhs: i64)))
+ }
+ Value::Integer(rhs) => Ok(Value::Integer(bast!(lhs: i64) + rhs)),
+ Value::Decimal(rhs) => Ok(Value::Decimal(bast!(lhs: f64) + rhs)),
+ _ => todo!(),
+ },
+ Value::Integer(lhs) => match rhs {
+ Value::Bool(rhs) => Ok(Value::Integer(lhs + bast!(rhs: i64))),
+ Value::Integer(rhs) => Ok(Value::Integer(lhs + rhs)),
+ Value::Decimal(rhs) => Ok(Value::Decimal(lhs as f64 + rhs)),
+ _ => todo!(),
+ },
+ Value::Decimal(lhs) => match rhs {
+ Value::Bool(rhs) => Ok(Value::Decimal(lhs + bast!(rhs: f64))),
+ Value::Integer(rhs) => Ok(Value::Decimal(lhs + rhs as f64)),
+ Value::Decimal(rhs) => Ok(Value::Decimal(lhs + rhs)),
+ _ => todo!(),
+ },
+ Value::String(lhs) => match rhs {
+ Value::Bool(rhs) => Ok(Value::String(Cow::from(format!("{lhs}{rhs}")))),
+ Value::Integer(rhs) => {
+ Ok(Value::String(Cow::from(format!("{lhs}{rhs}"))))
+ }
+ Value::Decimal(rhs) => {
+ Ok(Value::String(Cow::from(format!("{lhs}{rhs}"))))
+ }
+ Value::String(rhs) => {
+ Ok(Value::String(Cow::from(format!("{lhs}{rhs}"))))
+ }
+ _ => todo!(),
+ },
+ _ => todo!(),
+ },
+ BinOp::Sub => match lhs {
+ Value::Bool(lhs) => match rhs {
+ Value::Bool(rhs) => {
+ Ok(Value::Integer(bast!(lhs: i64) - bast!(rhs: i64)))
+ }
+ Value::Integer(rhs) => Ok(Value::Integer(bast!(lhs: i64) - rhs)),
+ Value::Decimal(rhs) => Ok(Value::Decimal(bast!(lhs: f64) - rhs)),
+ _ => todo!(),
+ },
+ Value::Integer(lhs) => match rhs {
+ Value::Bool(rhs) => Ok(Value::Integer(lhs - bast!(rhs: i64))),
+ Value::Integer(rhs) => Ok(Value::Integer(lhs - rhs)),
+ Value::Decimal(rhs) => Ok(Value::Decimal(lhs as f64 - rhs)),
+ _ => todo!(),
+ },
+ Value::Decimal(lhs) => match rhs {
+ Value::Bool(rhs) => Ok(Value::Decimal(lhs - bast!(rhs: f64))),
+ Value::Integer(rhs) => Ok(Value::Decimal(lhs - rhs as f64)),
+ Value::Decimal(rhs) => Ok(Value::Decimal(lhs - rhs)),
+ _ => todo!(),
+ },
+ _ => todo!(),
+ },
+ BinOp::Mul => match lhs {
+ Value::Bool(lhs) => match rhs {
+ Value::Bool(rhs) => {
+ Ok(Value::Integer(bast!(lhs: i64) * bast!(rhs: i64)))
+ }
+ Value::Integer(rhs) => Ok(Value::Integer(bast!(lhs: i64) * rhs)),
+ Value::Decimal(rhs) => Ok(Value::Decimal(bast!(lhs: f64) * rhs)),
+ _ => todo!(),
+ },
+ Value::Integer(lhs) => match rhs {
+ Value::Bool(rhs) => Ok(Value::Integer(lhs * bast!(rhs: i64))),
+ Value::Integer(rhs) => Ok(Value::Integer(lhs * rhs)),
+ Value::Decimal(rhs) => Ok(Value::Decimal(lhs as f64 * rhs)),
+ _ => todo!(),
+ },
+ Value::Decimal(lhs) => match rhs {
+ Value::Bool(rhs) => Ok(Value::Decimal(lhs * bast!(rhs: f64))),
+ Value::Integer(rhs) => Ok(Value::Decimal(lhs * rhs as f64)),
+ Value::Decimal(rhs) => Ok(Value::Decimal(lhs * rhs)),
+ _ => todo!(),
+ },
+ _ => todo!(),
+ },
+ BinOp::Div => match lhs {
+ Value::Bool(lhs) => match rhs {
+ Value::Bool(rhs) => {
+ Ok(Value::Integer(bast!(lhs: i64) / bast!(rhs: i64)))
+ }
+ Value::Integer(rhs) => Ok(Value::Integer(bast!(lhs: i64) / rhs)),
+ Value::Decimal(rhs) => Ok(Value::Decimal(bast!(lhs: f64) / rhs)),
+ _ => todo!(),
+ },
+ Value::Integer(lhs) => match rhs {
+ Value::Bool(rhs) => Ok(Value::Integer(lhs / bast!(rhs: i64))),
+ Value::Integer(rhs) => Ok(Value::Integer(lhs / rhs)),
+ Value::Decimal(rhs) => Ok(Value::Decimal(lhs as f64 / rhs)),
+ _ => todo!(),
+ },
+ Value::Decimal(lhs) => match rhs {
+ Value::Bool(rhs) => Ok(Value::Decimal(lhs / bast!(rhs: f64))),
+ Value::Integer(rhs) => Ok(Value::Decimal(lhs / rhs as f64)),
+ Value::Decimal(rhs) => Ok(Value::Decimal(lhs / rhs)),
+ _ => todo!(),
+ },
+ _ => todo!(),
+ },
+ BinOp::Mod => match lhs {
+ Value::Bool(lhs) => match rhs {
+ Value::Bool(rhs) => {
+ Ok(Value::Integer(bast!(lhs: i64) % bast!(rhs: i64)))
+ }
+ Value::Integer(rhs) => Ok(Value::Integer(bast!(lhs: i64) % rhs)),
+ Value::Decimal(rhs) => Ok(Value::Decimal(bast!(lhs: f64) % rhs)),
+ _ => todo!(),
+ },
+ Value::Integer(lhs) => match rhs {
+ Value::Bool(rhs) => Ok(Value::Integer(lhs % bast!(rhs: i64))),
+ Value::Integer(rhs) => Ok(Value::Integer(lhs % rhs)),
+ Value::Decimal(rhs) => Ok(Value::Decimal(lhs as f64 % rhs)),
+ _ => todo!(),
+ },
+ Value::Decimal(lhs) => match rhs {
+ Value::Bool(rhs) => Ok(Value::Decimal(lhs % bast!(rhs: f64))),
+ Value::Integer(rhs) => Ok(Value::Decimal(lhs % rhs as f64)),
+ Value::Decimal(rhs) => Ok(Value::Decimal(lhs % rhs)),
+ _ => todo!(),
+ },
+ _ => todo!(),
+ },
+ BinOp::Equal => match lhs {
+ Value::Bool(lhs) => match rhs {
+ Value::Bool(rhs) => Ok(Value::Bool(lhs == rhs)),
+ Value::Integer(rhs) => Ok(Value::Bool(bast!(lhs: i64) == rhs)),
+ Value::Decimal(rhs) => Ok(Value::Bool(bast!(lhs: f64) == rhs)),
+ _ => todo!(),
+ },
+ Value::Integer(lhs) => match rhs {
+ Value::Bool(rhs) => Ok(Value::Bool(lhs == bast!(rhs: i64))),
+ Value::Integer(rhs) => Ok(Value::Bool(lhs == rhs)),
+ Value::Decimal(rhs) => Ok(Value::Bool(lhs as f64 == rhs)),
+ _ => todo!(),
+ },
+ Value::Decimal(lhs) => match rhs {
+ Value::Bool(rhs) => Ok(Value::Bool(lhs == bast!(rhs: f64))),
+ Value::Integer(rhs) => Ok(Value::Bool(lhs == rhs as f64)),
+ Value::Decimal(rhs) => Ok(Value::Bool(lhs == rhs)),
+ _ => todo!(),
+ },
+ _ => todo!(),
+ },
+ BinOp::NotEqual => match lhs {
+ Value::Bool(lhs) => match rhs {
+ Value::Bool(rhs) => Ok(Value::Bool(lhs != rhs)),
+ Value::Integer(rhs) => Ok(Value::Bool(bast!(lhs: i64) != rhs)),
+ Value::Decimal(rhs) => Ok(Value::Bool(bast!(lhs: f64) != rhs)),
+ _ => todo!(),
+ },
+ Value::Integer(lhs) => match rhs {
+ Value::Bool(rhs) => Ok(Value::Bool(lhs != bast!(rhs: i64))),
+ Value::Integer(rhs) => Ok(Value::Bool(lhs != rhs)),
+ Value::Decimal(rhs) => Ok(Value::Bool(lhs as f64 != rhs)),
+ _ => todo!(),
+ },
+ Value::Decimal(lhs) => match rhs {
+ Value::Bool(rhs) => Ok(Value::Bool(lhs != bast!(rhs: f64))),
+ Value::Integer(rhs) => Ok(Value::Bool(lhs != rhs as f64)),
+ Value::Decimal(rhs) => Ok(Value::Bool(lhs != rhs)),
+ _ => todo!(),
+ },
+ _ => todo!(),
+ },
+ BinOp::GreaterThan => match lhs {
+ Value::Bool(lhs) => match rhs {
+ Value::Bool(rhs) => Ok(Value::Bool(lhs > rhs)),
+ Value::Integer(rhs) => Ok(Value::Bool(bast!(lhs: i64) > rhs)),
+ Value::Decimal(rhs) => Ok(Value::Bool(bast!(lhs: f64) > rhs)),
+ _ => todo!(),
+ },
+ Value::Integer(lhs) => match rhs {
+ Value::Bool(rhs) => Ok(Value::Bool(lhs > bast!(rhs: i64))),
+ Value::Integer(rhs) => Ok(Value::Bool(lhs > rhs)),
+ Value::Decimal(rhs) => Ok(Value::Bool((lhs as f64) > rhs)),
+ _ => todo!(),
+ },
+ Value::Decimal(lhs) => match rhs {
+ Value::Bool(rhs) => Ok(Value::Bool(lhs > bast!(rhs: f64))),
+ Value::Integer(rhs) => Ok(Value::Bool(lhs > rhs as f64)),
+ Value::Decimal(rhs) => Ok(Value::Bool(lhs > rhs)),
+ _ => todo!(),
+ },
+ _ => todo!(),
+ },
+ BinOp::LessThan => match lhs {
+ Value::Bool(lhs) => match rhs {
+ Value::Bool(rhs) => Ok(Value::Bool(lhs < rhs)),
+ Value::Integer(rhs) => Ok(Value::Bool(bast!(lhs: i64) < rhs)),
+ Value::Decimal(rhs) => Ok(Value::Bool(bast!(lhs: f64) < rhs)),
+ _ => todo!(),
+ },
+ Value::Integer(lhs) => match rhs {
+ Value::Bool(rhs) => Ok(Value::Bool(lhs < bast!(rhs: i64))),
+ Value::Integer(rhs) => Ok(Value::Bool(lhs < rhs)),
+ Value::Decimal(rhs) => Ok(Value::Bool((lhs as f64) < rhs)),
+ _ => todo!(),
+ },
+ Value::Decimal(lhs) => match rhs {
+ Value::Bool(rhs) => Ok(Value::Bool(lhs < bast!(rhs: f64))),
+ Value::Integer(rhs) => Ok(Value::Bool(lhs < rhs as f64)),
+ Value::Decimal(rhs) => Ok(Value::Bool(lhs < rhs)),
+ _ => todo!(),
+ },
+ _ => todo!(),
+ },
+ BinOp::GreaterEqual => match lhs {
+ Value::Bool(lhs) => match rhs {
+ Value::Bool(rhs) => Ok(Value::Bool(lhs >= rhs)),
+ Value::Integer(rhs) => Ok(Value::Bool(bast!(lhs: i64) >= rhs)),
+ Value::Decimal(rhs) => Ok(Value::Bool(bast!(lhs: f64) >= rhs)),
+ _ => todo!(),
+ },
+ Value::Integer(lhs) => match rhs {
+ Value::Bool(rhs) => Ok(Value::Bool(lhs >= bast!(rhs: i64))),
+ Value::Integer(rhs) => Ok(Value::Bool(lhs >= rhs)),
+ Value::Decimal(rhs) => Ok(Value::Bool((lhs as f64) >= rhs)),
+ _ => todo!(),
+ },
+ Value::Decimal(lhs) => match rhs {
+ Value::Bool(rhs) => Ok(Value::Bool(lhs >= bast!(rhs: f64))),
+ Value::Integer(rhs) => Ok(Value::Bool(lhs >= rhs as f64)),
+ Value::Decimal(rhs) => Ok(Value::Bool(lhs >= rhs)),
+ _ => todo!(),
+ },
+ _ => todo!(),
+ },
+ BinOp::LessEqual => match lhs {
+ Value::Bool(lhs) => match rhs {
+ Value::Bool(rhs) => Ok(Value::Bool(lhs <= rhs)),
+ Value::Integer(rhs) => Ok(Value::Bool(bast!(lhs: i64) <= rhs)),
+ Value::Decimal(rhs) => Ok(Value::Bool(bast!(lhs: f64) <= rhs)),
+ _ => todo!(),
+ },
+ Value::Integer(lhs) => match rhs {
+ Value::Bool(rhs) => Ok(Value::Bool(lhs <= bast!(rhs: i64))),
+ Value::Integer(rhs) => Ok(Value::Bool(lhs <= rhs)),
+ Value::Decimal(rhs) => Ok(Value::Bool(lhs as f64 <= rhs)),
+ _ => todo!(),
+ },
+ Value::Decimal(lhs) => match rhs {
+ Value::Bool(rhs) => Ok(Value::Bool(lhs <= bast!(rhs: f64))),
+ Value::Integer(rhs) => Ok(Value::Bool(lhs <= rhs as f64)),
+ Value::Decimal(rhs) => Ok(Value::Bool(lhs <= rhs)),
+ _ => todo!(),
+ },
+ _ => todo!(),
+ },
+ BinOp::And => match lhs {
+ Value::Bool(lhs) => match rhs {
+ Value::Bool(rhs) => {
+ Ok(Value::Integer(bast!(lhs: i64) & bast!(rhs: i64)))
+ }
+ Value::Integer(rhs) => Ok(Value::Integer(bast!(lhs: i64) & rhs)),
+ _ => todo!(),
+ },
+ Value::Integer(lhs) => match rhs {
+ Value::Bool(rhs) => Ok(Value::Integer(lhs & bast!(rhs: i64))),
+ Value::Integer(rhs) => Ok(Value::Integer(lhs & rhs)),
+ _ => todo!(),
+ },
+ _ => todo!(),
+ },
+ BinOp::Or => match lhs {
+ Value::Bool(lhs) => match rhs {
+ Value::Bool(rhs) => {
+ Ok(Value::Integer(bast!(lhs: i64) | bast!(rhs: i64)))
+ }
+ Value::Integer(rhs) => Ok(Value::Integer(bast!(lhs: i64) | rhs)),
+ _ => todo!(),
+ },
+ Value::Integer(lhs) => match rhs {
+ Value::Bool(rhs) => Ok(Value::Integer(lhs | bast!(rhs: i64))),
+ Value::Integer(rhs) => Ok(Value::Integer(lhs | rhs)),
+ _ => todo!(),
+ },
+ _ => todo!(),
+ },
+ }
+ }
+ Expr::UnOp(op, expr) => {
+ let value = self.eval(&expr.inner)?;
+
+ match op.inner {
+ UnOp::Not => match value {
+ Value::Bool(value) => Ok(Value::Bool(!value)),
+ Value::Integer(value) => Ok(Value::Bool(value == 0)),
+ Value::Decimal(value) => Ok(Value::Bool(value == 0.0)),
+ _ => todo!(),
+ },
+ UnOp::Plus => todo!(),
+ UnOp::Neg => match value {
+ Value::Bool(value) => Ok(Value::Integer(-bast!(value: i64))),
+ Value::Integer(value) => Ok(Value::Integer(-value)),
+ Value::Decimal(value) => Ok(Value::Decimal(-value)),
+ _ => todo!(),
+ },
+ }
+ }
+ Expr::Group(expr) => self.eval(&expr.inner),
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use chumsky::span::WrappingSpan as _;
+
+ use crate::filter::ast::Ident;
+
+ use super::*;
+
+ fn binop<'src>(op: BinOp, lhs: Expr<'src>, rhs: Expr<'src>) -> Expr<'src> {
+ Expr::BinOp(
+ SimpleSpan::from(0..1).make_wrapped(op),
+ Box::new(SimpleSpan::from(0..1).make_wrapped(lhs)),
+ Box::new(SimpleSpan::from(0..1).make_wrapped(rhs)),
+ )
+ }
+
+ fn unop(op: UnOp, lhs: Expr<'_>) -> Expr<'_> {
+ Expr::UnOp(
+ SimpleSpan::from(0..1).make_wrapped(op),
+ Box::new(SimpleSpan::from(0..1).make_wrapped(lhs)),
+ )
+ }
+
+ #[test]
+ fn bool() {
+ let mut vm = Vm::new();
+
+ vm.add_scope("bool_false", ScopeEntry::Bool(false));
+ vm.add_scope("bool_true", ScopeEntry::Bool(true));
+
+ assert_eq!(Ok(Value::Bool(false)), vm.eval(&Expr::Bool(false)));
+ assert_eq!(Ok(Value::Bool(true)), vm.eval(&Expr::Bool(true)));
+
+ assert_eq!(
+ Ok(Value::Bool(true)),
+ vm.eval(&unop(UnOp::Not, Expr::Bool(false)))
+ );
+ assert_eq!(
+ Ok(Value::Bool(false)),
+ vm.eval(&unop(UnOp::Not, Expr::Bool(true)))
+ );
+
+ assert_eq!(
+ Ok(Value::Bool(false)),
+ vm.eval(&Expr::Ident(Ident("bool_false".into())))
+ );
+ assert_eq!(
+ Ok(Value::Bool(true)),
+ vm.eval(&Expr::Ident(Ident("bool_true".into())))
+ );
+
+ assert_eq!(
+ Ok(Value::Bool(true)),
+ vm.eval(&unop(UnOp::Not, Expr::Ident(Ident("bool_false".into()))))
+ );
+ assert_eq!(
+ Ok(Value::Bool(false)),
+ vm.eval(&unop(UnOp::Not, Expr::Ident(Ident("bool_true".into()))))
+ );
+ }
+
+ #[test]
+ fn integer() {
+ let mut vm = Vm::new();
+
+ vm.add_scope("integer_zero", ScopeEntry::Integer(0));
+ vm.add_scope("integer_one", ScopeEntry::Integer(1));
+
+ assert_eq!(Ok(Value::Integer(0)), vm.eval(&Expr::Integer(0)));
+ assert_eq!(Ok(Value::Integer(1)), vm.eval(&Expr::Integer(1)));
+
+ assert_eq!(
+ Ok(Value::Integer(2)),
+ vm.eval(&binop(BinOp::Add, Expr::Integer(1), Expr::Integer(1)))
+ );
+ assert_eq!(
+ Ok(Value::Integer(0)),
+ vm.eval(&binop(BinOp::Sub, Expr::Integer(1), Expr::Integer(1)))
+ );
+
+ assert_eq!(
+ Ok(Value::Integer(2)),
+ vm.eval(&binop(
+ BinOp::Add,
+ Expr::Ident(Ident("integer_one".into())),
+ Expr::Ident(Ident("integer_one".into()))
+ ))
+ );
+ assert_eq!(
+ Ok(Value::Integer(0)),
+ vm.eval(&binop(
+ BinOp::Sub,
+ Expr::Ident(Ident("integer_one".into())),
+ Expr::Ident(Ident("integer_one".into()))
+ ))
+ );
+ }
+
+ #[test]
+ fn decimal() {
+ let mut vm = Vm::new();
+
+ vm.add_scope("decimal_zero", ScopeEntry::Decimal(0.0));
+ vm.add_scope("decimal_one", ScopeEntry::Decimal(1.0));
+
+ assert_eq!(Ok(Value::Decimal(0.0)), vm.eval(&Expr::Decimal(0.0)));
+ assert_eq!(Ok(Value::Decimal(1.0)), vm.eval(&Expr::Decimal(1.0)));
+
+ assert_eq!(
+ Ok(Value::Decimal(2.0)),
+ vm.eval(&binop(BinOp::Add, Expr::Decimal(1.0), Expr::Decimal(1.0)))
+ );
+ assert_eq!(
+ Ok(Value::Decimal(0.0)),
+ vm.eval(&binop(BinOp::Sub, Expr::Decimal(1.0), Expr::Decimal(1.0)))
+ );
+
+ assert_eq!(
+ Ok(Value::Decimal(2.0)),
+ vm.eval(&binop(
+ BinOp::Add,
+ Expr::Ident(Ident("decimal_one".into())),
+ Expr::Ident(Ident("decimal_one".into()))
+ ))
+ );
+ assert_eq!(
+ Ok(Value::Decimal(0.0)),
+ vm.eval(&binop(
+ BinOp::Sub,
+ Expr::Ident(Ident("decimal_one".into())),
+ Expr::Ident(Ident("decimal_one".into()))
+ ))
+ );
+ }
+}
diff --git a/sable-bases/src/filter/ast.rs b/sable-bases/src/filter/ast.rs
new file mode 100644
index 0000000..62167bc
+use std::borrow::Cow;
+
+use chumsky::prelude::Spanned;
+
+#[derive(Debug, Clone, Hash, PartialEq, Eq)]
+pub struct Ident<'src>(pub Cow<'src, str>);
+
+#[derive(Debug, Clone, PartialEq)]
+pub enum Token<'src> {
+ Ident(Ident<'src>),
+
+ Bool(bool),
+ Integer(i64),
+ Decimal(f64),
+ String(Cow<'src, str>),
+ Regex(Cow<'src, str>),
+
+ Add,
+ Sub,
+ Mul,
+ Div,
+ Mod,
+
+ Equal,
+ NotEqual,
+ GreaterThan,
+ LessThan,
+ GreaterEqual,
+ LessEqual,
+
+ Not,
+
+ And,
+ Or,
+
+ ParenOpen,
+ ParenClose,
+ BracketOpen,
+ BracketClose,
+
+ Comma,
+ Period,
+}
+
+#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq)]
+pub enum BinOp {
+ Add,
+ Sub,
+ Mul,
+ Div,
+ Mod,
+
+ Equal,
+ NotEqual,
+ GreaterThan,
+ LessThan,
+ GreaterEqual,
+ LessEqual,
+
+ And,
+ Or,
+}
+
+#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq)]
+pub enum UnOp {
+ Not,
+
+ Plus,
+ Neg,
+}
+
+#[derive(Debug, Clone, PartialEq)]
+pub enum Expr<'src> {
+ Ident(Ident<'src>),
+
+ Bool(bool),
+ Integer(i64),
+ Decimal(f64),
+ String(Cow<'src, str>),
+ Regex(Cow<'src, str>),
+
+ Function(Box<Spanned<Self>>, Spanned<Vec<Spanned<Self>>>),
+ Method(
+ Box<Spanned<Self>>,
+ Spanned<Ident<'src>>,
+ Spanned<Vec<Spanned<Self>>>,
+ ),
+
+ Member(Box<Spanned<Self>>, Spanned<Ident<'src>>),
+ Index(Box<Spanned<Self>>, Box<Spanned<Self>>),
+
+ BinOp(Spanned<BinOp>, Box<Spanned<Self>>, Box<Spanned<Self>>),
+ UnOp(Spanned<UnOp>, Box<Spanned<Self>>),
+
+ Group(Box<Spanned<Self>>),
+}
diff --git a/sable-bases/src/filter/convert.rs b/sable-bases/src/filter/convert.rs
new file mode 100644
index 0000000..8a660ef
+use itertools::Itertools as _;
+
+use super::ast::{BinOp, Expr, UnOp};
+
+pub fn ast_to_js(expr: &Expr<'_>) -> String {
+ inner(expr)
+}
+
+// TODO: #[recursive]
+fn inner(expr: &Expr<'_>) -> String {
+ match expr {
+ Expr::Ident(ident) => ident.0.to_string(),
+ Expr::Bool(value) => value.to_string(),
+ Expr::Integer(value) => value.to_string(),
+ Expr::Decimal(value) => format!("{value:?}"),
+ Expr::String(value) => format!("{value:?}"),
+ Expr::Regex(value) => format!("/{value:?}/"),
+ Expr::Function(expr, params) => {
+ let mut expr = inner(&expr.inner);
+
+ if expr == "if" {
+ expr = "__if_expr__".to_string();
+ }
+
+ let params = params
+ .inner
+ .iter()
+ .map(|param| inner(¶m.inner))
+ .join(",");
+
+ format!("({expr})({params})")
+ }
+ Expr::Method(expr, name, params) => {
+ let expr = inner(&expr.inner);
+ let params = params
+ .inner
+ .iter()
+ .map(|param| inner(¶m.inner))
+ .join(",");
+
+ format!("({expr}).{}({params})", name.0)
+ }
+ Expr::Member(expr, name) => {
+ let expr = inner(&expr.inner);
+
+ format!("({expr}).{}", name.0)
+ }
+ Expr::Index(expr, index) => {
+ let expr = inner(&expr.inner);
+ let index = inner(&index.inner);
+
+ format!("({expr})[{index}]")
+ }
+ Expr::BinOp(op, lhs, rhs) => {
+ let lhs = inner(&lhs.inner);
+ let rhs = inner(&rhs.inner);
+
+ let sym = match op.inner {
+ BinOp::Add => "+",
+ BinOp::Sub => "-",
+ BinOp::Mul => "*",
+ BinOp::Div => "/",
+ BinOp::Mod => "%",
+ BinOp::Equal => "==",
+ BinOp::NotEqual => "!=",
+ BinOp::GreaterThan => ">",
+ BinOp::LessThan => "<",
+ BinOp::GreaterEqual => ">=",
+ BinOp::LessEqual => "<=",
+ BinOp::And => "&&",
+ BinOp::Or => "||",
+ };
+
+ format!("({lhs} {sym} {rhs})")
+ }
+ Expr::UnOp(op, expr) => {
+ let expr = inner(&expr.inner);
+
+ let sym = match op.inner {
+ UnOp::Not => "!",
+ UnOp::Plus => "+",
+ UnOp::Neg => "-",
+ };
+
+ format!("({sym}{expr})")
+ }
+ Expr::Group(expr) => format!("({})", inner(&expr.inner)),
+ }
+}
+
+#[allow(
+ clippy::too_many_lines,
+ clippy::cognitive_complexity,
+ reason = "theses are tests..."
+)]
+#[cfg(test)]
+mod tests {
+ use chumsky::span::{SimpleSpan, Spanned};
+
+ use super::super::ast::{BinOp, Expr, Ident, UnOp};
+
+ use super::ast_to_js;
+
+ fn s<T>(t: T) -> Spanned<T> {
+ Spanned {
+ span: SimpleSpan::from(0..1),
+ inner: t,
+ }
+ }
+
+ #[test]
+ fn simple() {
+ assert_eq!("test", ast_to_js(&Expr::Ident(Ident("test".into()))));
+
+ assert_eq!("true", ast_to_js(&Expr::Bool(true)));
+ assert_eq!("false", ast_to_js(&Expr::Bool(false)));
+
+ assert_eq!("0", ast_to_js(&Expr::Integer(0)));
+ assert_eq!("1", ast_to_js(&Expr::Integer(1)));
+
+ assert_eq!("0.0", ast_to_js(&Expr::Decimal(0.0)));
+ assert_eq!("1.0", ast_to_js(&Expr::Decimal(1.0)));
+ assert_eq!(
+ "1.000000000000001",
+ ast_to_js(&Expr::Decimal(1.000_000_000_000_001))
+ );
+
+ assert_eq!("\"test\\\"s\"", ast_to_js(&Expr::String("test\"s".into())));
+
+ assert_eq!(
+ "(test)()",
+ ast_to_js(&Expr::Function(
+ Box::new(s(Expr::Ident(Ident("test".into())))),
+ s(vec![]),
+ ))
+ );
+ assert_eq!(
+ "(test)(a)",
+ ast_to_js(&Expr::Function(
+ Box::new(s(Expr::Ident(Ident("test".into())))),
+ s(vec![s(Expr::Ident(Ident("a".into())))]),
+ ))
+ );
+ assert_eq!(
+ "(test)(a,b)",
+ ast_to_js(&Expr::Function(
+ Box::new(s(Expr::Ident(Ident("test".into())))),
+ s(vec![
+ s(Expr::Ident(Ident("a".into()))),
+ s(Expr::Ident(Ident("b".into()))),
+ ]),
+ ))
+ );
+
+ assert_eq!(
+ "(test).test()",
+ ast_to_js(&Expr::Method(
+ Box::new(s(Expr::Ident(Ident("test".into())))),
+ s(Ident("test".into())),
+ s(vec![]),
+ ))
+ );
+ assert_eq!(
+ "(test).test(a)",
+ ast_to_js(&Expr::Method(
+ Box::new(s(Expr::Ident(Ident("test".into())))),
+ s(Ident("test".into())),
+ s(vec![s(Expr::Ident(Ident("a".into())))]),
+ ))
+ );
+ assert_eq!(
+ "(test).test(a,b)",
+ ast_to_js(&Expr::Method(
+ Box::new(s(Expr::Ident(Ident("test".into())))),
+ s(Ident("test".into())),
+ s(vec![
+ s(Expr::Ident(Ident("a".into()))),
+ s(Expr::Ident(Ident("b".into()))),
+ ]),
+ ))
+ );
+
+ assert_eq!(
+ "(test).test",
+ ast_to_js(&Expr::Member(
+ Box::new(s(Expr::Ident(Ident("test".into())))),
+ s(Ident("test".into())),
+ ))
+ );
+
+ assert_eq!(
+ "(test)[1]",
+ ast_to_js(&Expr::Index(
+ Box::new(s(Expr::Ident(Ident("test".into())))),
+ Box::new(s(Expr::Integer(1))),
+ ))
+ );
+
+ assert_eq!(
+ "(a + b)",
+ ast_to_js(&Expr::BinOp(
+ s(BinOp::Add),
+ Box::new(s(Expr::Ident(Ident("a".into())))),
+ Box::new(s(Expr::Ident(Ident("b".into())))),
+ ))
+ );
+ assert_eq!(
+ "(a - b)",
+ ast_to_js(&Expr::BinOp(
+ s(BinOp::Sub),
+ Box::new(s(Expr::Ident(Ident("a".into())))),
+ Box::new(s(Expr::Ident(Ident("b".into())))),
+ ))
+ );
+ assert_eq!(
+ "(a * b)",
+ ast_to_js(&Expr::BinOp(
+ s(BinOp::Mul),
+ Box::new(s(Expr::Ident(Ident("a".into())))),
+ Box::new(s(Expr::Ident(Ident("b".into())))),
+ ))
+ );
+ assert_eq!(
+ "(a / b)",
+ ast_to_js(&Expr::BinOp(
+ s(BinOp::Div),
+ Box::new(s(Expr::Ident(Ident("a".into())))),
+ Box::new(s(Expr::Ident(Ident("b".into())))),
+ ))
+ );
+ assert_eq!(
+ "(a % b)",
+ ast_to_js(&Expr::BinOp(
+ s(BinOp::Mod),
+ Box::new(s(Expr::Ident(Ident("a".into())))),
+ Box::new(s(Expr::Ident(Ident("b".into())))),
+ ))
+ );
+ assert_eq!(
+ "(a == b)",
+ ast_to_js(&Expr::BinOp(
+ s(BinOp::Equal),
+ Box::new(s(Expr::Ident(Ident("a".into())))),
+ Box::new(s(Expr::Ident(Ident("b".into())))),
+ ))
+ );
+ assert_eq!(
+ "(a != b)",
+ ast_to_js(&Expr::BinOp(
+ s(BinOp::NotEqual),
+ Box::new(s(Expr::Ident(Ident("a".into())))),
+ Box::new(s(Expr::Ident(Ident("b".into())))),
+ ))
+ );
+ assert_eq!(
+ "(a > b)",
+ ast_to_js(&Expr::BinOp(
+ s(BinOp::GreaterThan),
+ Box::new(s(Expr::Ident(Ident("a".into())))),
+ Box::new(s(Expr::Ident(Ident("b".into())))),
+ ))
+ );
+ assert_eq!(
+ "(a < b)",
+ ast_to_js(&Expr::BinOp(
+ s(BinOp::LessThan),
+ Box::new(s(Expr::Ident(Ident("a".into())))),
+ Box::new(s(Expr::Ident(Ident("b".into())))),
+ ))
+ );
+ assert_eq!(
+ "(a >= b)",
+ ast_to_js(&Expr::BinOp(
+ s(BinOp::GreaterEqual),
+ Box::new(s(Expr::Ident(Ident("a".into())))),
+ Box::new(s(Expr::Ident(Ident("b".into())))),
+ ))
+ );
+ assert_eq!(
+ "(a <= b)",
+ ast_to_js(&Expr::BinOp(
+ s(BinOp::LessEqual),
+ Box::new(s(Expr::Ident(Ident("a".into())))),
+ Box::new(s(Expr::Ident(Ident("b".into())))),
+ ))
+ );
+ assert_eq!(
+ "(a && b)",
+ ast_to_js(&Expr::BinOp(
+ s(BinOp::And),
+ Box::new(s(Expr::Ident(Ident("a".into())))),
+ Box::new(s(Expr::Ident(Ident("b".into())))),
+ ))
+ );
+ assert_eq!(
+ "(a || b)",
+ ast_to_js(&Expr::BinOp(
+ s(BinOp::Or),
+ Box::new(s(Expr::Ident(Ident("a".into())))),
+ Box::new(s(Expr::Ident(Ident("b".into())))),
+ ))
+ );
+
+ assert_eq!(
+ "(!a)",
+ ast_to_js(&Expr::UnOp(
+ s(UnOp::Not),
+ Box::new(s(Expr::Ident(Ident("a".into())))),
+ ))
+ );
+ assert_eq!(
+ "(+a)",
+ ast_to_js(&Expr::UnOp(
+ s(UnOp::Plus),
+ Box::new(s(Expr::Ident(Ident("a".into())))),
+ ))
+ );
+ assert_eq!(
+ "(-a)",
+ ast_to_js(&Expr::UnOp(
+ s(UnOp::Neg),
+ Box::new(s(Expr::Ident(Ident("a".into())))),
+ ))
+ );
+
+ assert_eq!(
+ "(a)",
+ ast_to_js(&Expr::Group(Box::new(s(Expr::Ident(Ident("a".into())))),))
+ );
+ }
+}
diff --git a/sable-bases/src/filter/mod.rs b/sable-bases/src/filter/mod.rs
new file mode 100644
index 0000000..00c99a8
+pub mod ast;
+pub mod convert;
+pub mod parser;
+pub mod types;
+
+#[derive(Debug, Clone, Hash, PartialEq, Eq)]
+pub struct Duration {
+ inner: jiff::SignedDuration,
+}
diff --git a/sable-bases/src/filter/parser.rs b/sable-bases/src/filter/parser.rs
new file mode 100644
index 0000000..a886ca3
+use std::borrow::Cow;
+
+use chumsky::{
+ input::MappedInput,
+ pratt::{infix, left, postfix, prefix},
+ prelude::*,
+};
+
+use super::ast::{BinOp, Expr, Ident, Token, UnOp};
+
+pub fn lexer<'src>()
+-> impl Parser<'src, &'src str, Vec<Spanned<Token<'src>>>, extra::Err<Rich<'src, char>>> {
+ recursive(|_token| {
+ choice((
+ text::ident().map(|s| match s {
+ "true" => Token::Bool(true),
+ "false" => Token::Bool(false),
+ s => Token::Ident(Ident(Cow::from(s))),
+ }),
+ just('"')
+ .ignore_then(none_of('"').repeated().to_slice())
+ .then_ignore(just('"'))
+ .map(|s| Token::String(Cow::from(s))),
+ just("+").to(Token::Add),
+ just("-").to(Token::Sub),
+ just("*").to(Token::Mul),
+ just("/").to(Token::Div),
+ just("%").to(Token::Mod),
+ just("==").to(Token::Equal),
+ just("!=").to(Token::NotEqual),
+ just(">").to(Token::GreaterThan),
+ just("<").to(Token::LessThan),
+ just(">=").to(Token::GreaterEqual),
+ just("<=").to(Token::LessEqual),
+ just("!").to(Token::Not),
+ just("&&").to(Token::And),
+ just("||").to(Token::Or),
+ just("(").to(Token::ParenOpen),
+ just(")").to(Token::ParenClose),
+ just("[").to(Token::BracketOpen),
+ just("]").to(Token::BracketClose),
+ just(",").to(Token::Comma),
+ just(".").to(Token::Period),
+ text::int(10)
+ .then(just('.').then(text::digits(10)).or_not())
+ .to_slice()
+ .from_str()
+ .unwrapped()
+ .map(Token::Decimal),
+ text::int(10)
+ .to_slice()
+ .from_str()
+ .unwrapped()
+ .map(Token::Integer),
+ ))
+ .spanned()
+ .padded()
+ })
+ .repeated()
+ .collect()
+}
+
+#[cfg(test)]
+mod test_lexer {
+ use chumsky::prelude::*;
+
+ use super::*;
+
+ #[rstest::rstest]
+ #[case(
+ r#"note["link-data"] == "fanfiction""#,
+ vec![
+ (Token::Ident(Ident("note".into())).with_span(SimpleSpan::from(0..4))),
+ (Token::BracketOpen.with_span(SimpleSpan::from(4..5))),
+ (Token::String("link-data".into()).with_span(SimpleSpan::from(5..16))),
+ (Token::BracketClose.with_span(SimpleSpan::from(16..17))),
+ (Token::Equal.with_span(SimpleSpan::from(18..20))),
+ (Token::String("fanfiction".into()).with_span(SimpleSpan::from(21..33))),
+ ],
+ )]
+ fn test(#[case] input: &str, #[case] expected: Vec<Spanned<Token<'_>>>) {
+ let tokens = lexer().parse(input);
+ assert!(!tokens.has_errors());
+ assert_eq!(tokens.into_output(), Some(expected));
+ }
+
+ #[test]
+ fn setup_test() {
+ let tokens = lexer().parse(r#"note["link-data"] == "fanfiction""#);
+ assert!(!tokens.has_errors());
+ assert_eq!(
+ tokens.into_output(),
+ Some(vec![
+ (Token::Ident(Ident("note".into())).with_span(SimpleSpan::from(0..4))),
+ (Token::BracketOpen.with_span(SimpleSpan::from(4..5))),
+ (Token::String("link-data".into()).with_span(SimpleSpan::from(5..16))),
+ (Token::BracketClose.with_span(SimpleSpan::from(16..17))),
+ (Token::Equal.with_span(SimpleSpan::from(18..20))),
+ (Token::String("fanfiction".into()).with_span(SimpleSpan::from(21..33))),
+ ])
+ );
+ }
+}
+
+pub fn parser<'tokens, 'src: 'tokens>() -> impl Parser<
+ 'tokens,
+ MappedInput<'tokens, Token<'src>, SimpleSpan, &'tokens [Spanned<Token<'src>>]>,
+ Spanned<Expr<'src>>,
+ extra::Err<Rich<'tokens, Token<'src>>>,
+> {
+ recursive(|expr| {
+ let value = select! {
+ Token::Bool(b) => Expr::Bool(b),
+ Token::Integer(i) => Expr::Integer(i),
+ Token::Decimal(f) => Expr::Decimal(f),
+ Token::String(s) => Expr::String(s),
+ }
+ .labelled("value");
+
+ let ident = select! { Token::Ident(x) => x }.labelled("identifier");
+ let ident_expr = select! { Token::Ident(x) => Expr::Ident(x) }.labelled("identifier");
+
+ let params = expr
+ .clone()
+ .separated_by(just(Token::Comma))
+ .allow_trailing()
+ .collect::<Vec<_>>()
+ .delimited_by(just(Token::ParenOpen), just(Token::ParenClose))
+ .spanned();
+
+ let group = expr
+ .clone()
+ .delimited_by(just(Token::ParenOpen), just(Token::ParenClose))
+ .map(|inner| Expr::Group(Box::new(inner)))
+ .boxed();
+
+ let indexed = expr
+ .clone()
+ .delimited_by(just(Token::BracketOpen), just(Token::BracketClose));
+
+ choice((value, ident_expr, group)).spanned().pratt((
+ postfix(
+ 16,
+ just(Token::Period)
+ .ignore_then(ident.spanned())
+ .then(params.clone()),
+ |name, (method, params), e| {
+ Expr::Method(Box::new(name), method, params).with_span(e.span())
+ },
+ ),
+ postfix(
+ 15,
+ just(Token::Period).ignore_then(ident.spanned()),
+ |name, method, e| Expr::Member(Box::new(name), method).with_span(e.span()),
+ ),
+ postfix(14, params.clone(), |name, params, e| {
+ Expr::Function(Box::new(name), params).with_span(e.span())
+ }),
+ postfix(13, indexed, |name, params, e| {
+ Expr::Index(Box::new(name), Box::new(params)).with_span(e.span())
+ }),
+ //
+ infix(
+ left(12),
+ select! { Token::Or = e => BinOp::Or.with_span(e.span()) },
+ |x, op, y, e| Expr::BinOp(op, Box::new(x), Box::new(y)).with_span(e.span()),
+ ),
+ infix(
+ left(11),
+ select! { Token::And = e => BinOp::And.with_span(e.span()) },
+ |x, op, y, e| Expr::BinOp(op, Box::new(x), Box::new(y)).with_span(e.span()),
+ ),
+ //
+ infix(
+ left(7),
+ select! { Token::Equal = e => BinOp::Equal.with_span(e.span()) },
+ |x, op, y, e| Expr::BinOp(op, Box::new(x), Box::new(y)).with_span(e.span()),
+ ),
+ infix(
+ left(7),
+ select! { Token::NotEqual = e => BinOp::NotEqual.with_span(e.span()) },
+ |x, op, y, e| Expr::BinOp(op, Box::new(x), Box::new(y)).with_span(e.span()),
+ ),
+ //
+ infix(
+ left(6),
+ select! { Token::GreaterThan = e => BinOp::GreaterThan.with_span(e.span()) },
+ |x, op, y, e| Expr::BinOp(op, Box::new(x), Box::new(y)).with_span(e.span()),
+ ),
+ infix(
+ left(6),
+ select! { Token::GreaterEqual = e => BinOp::GreaterEqual.with_span(e.span()) },
+ |x, op, y, e| Expr::BinOp(op, Box::new(x), Box::new(y)).with_span(e.span()),
+ ),
+ infix(
+ left(6),
+ select! { Token::LessThan = e => BinOp::LessThan.with_span(e.span()) },
+ |x, op, y, e| Expr::BinOp(op, Box::new(x), Box::new(y)).with_span(e.span()),
+ ),
+ infix(
+ left(6),
+ select! { Token::LessEqual = e => BinOp::LessEqual.with_span(e.span()) },
+ |x, op, y, e| Expr::BinOp(op, Box::new(x), Box::new(y)).with_span(e.span()),
+ ),
+ //
+ infix(
+ left(4),
+ select! { Token::Add = e => BinOp::Add.with_span(e.span()) },
+ |x, op, y, e| Expr::BinOp(op, Box::new(x), Box::new(y)).with_span(e.span()),
+ ),
+ infix(
+ left(4),
+ select! { Token::Sub = e => BinOp::Sub.with_span(e.span()) },
+ |x, op, y, e| Expr::BinOp(op, Box::new(x), Box::new(y)).with_span(e.span()),
+ ),
+ //
+ infix(
+ left(3),
+ select! { Token::Mul = e => BinOp::Mul.with_span(e.span()) },
+ |x, op, y, e| Expr::BinOp(op, Box::new(x), Box::new(y)).with_span(e.span()),
+ ),
+ infix(
+ left(3),
+ select! { Token::Div = e => BinOp::Div.with_span(e.span()) },
+ |x, op, y, e| Expr::BinOp(op, Box::new(x), Box::new(y)).with_span(e.span()),
+ ),
+ infix(
+ left(3),
+ select! { Token::Mod = e => BinOp::Mod.with_span(e.span()) },
+ |x, op, y, e| Expr::BinOp(op, Box::new(x), Box::new(y)).with_span(e.span()),
+ ),
+ //
+ prefix(
+ 2,
+ select! { Token::Sub = e => UnOp::Neg.with_span(e.span()) },
+ |op, x, e| Expr::UnOp(op, Box::new(x)).with_span(e.span()),
+ ),
+ prefix(
+ 2,
+ select! { Token::Not = e => UnOp::Not.with_span(e.span()) },
+ |op, x, e| Expr::UnOp(op, Box::new(x)).with_span(e.span()),
+ ),
+ ))
+ })
+}
+
+#[cfg(test)]
+mod test_parser {
+ use chumsky::prelude::*;
+
+ use super::*;
+
+ #[test]
+ fn setup_test() {
+ let tokens = lexer().parse(r#"note["link-data"] == "fanfiction""#);
+ assert!(!tokens.has_errors());
+ assert_eq!(
+ tokens.into_output(),
+ Some(vec![
+ (Token::Ident(Ident("note".into())).with_span(SimpleSpan::from(0..4))),
+ (Token::BracketOpen.with_span(SimpleSpan::from(4..5))),
+ (Token::String("link-data".into()).with_span(SimpleSpan::from(5..16))),
+ (Token::BracketClose.with_span(SimpleSpan::from(16..17))),
+ (Token::Equal.with_span(SimpleSpan::from(18..20))),
+ (Token::String("fanfiction".into()).with_span(SimpleSpan::from(21..33))),
+ ])
+ );
+ }
+}
diff --git a/sable-bases/src/filter/types.rs b/sable-bases/src/filter/types.rs
new file mode 100644
index 0000000..6dea888
+#![allow(
+ dead_code,
+ unreachable_pub,
+ clippy::cast_sign_loss,
+ clippy::cast_possible_truncation,
+ clippy::cast_possible_wrap,
+ clippy::todo
+)]
+
+use std::{cmp::PartialEq, collections::BTreeMap, string::String as StdString};
+
+use camino::Utf8PathBuf;
+
+macro_rules! bast {
+ ($name:ident: f64 ) => {
+ if $name.0 { Decimal(1.0) } else { Decimal(0.0) }
+ };
+ ($name:ident: i64 ) => {
+ if $name.0 { Integer(1) } else { Integer(0) }
+ };
+}
+
+#[derive(Debug)]
+pub enum Type {
+ Bool,
+ Integer,
+ Decimal,
+ String,
+ Array,
+ Object,
+ Date,
+ Duration,
+ Link,
+ File,
+}
+
+impl std::fmt::Display for Type {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ Self::Bool => write!(f, "bool"),
+ Self::Integer => write!(f, "integer"),
+ Self::Decimal => write!(f, "decimal"),
+ Self::String => write!(f, "string"),
+ Self::Array => write!(f, "array"),
+ Self::Object => write!(f, "object"),
+ Self::Date => write!(f, "date"),
+ Self::Duration => write!(f, "duration"),
+ Self::Link => write!(f, "link"),
+ Self::File => write!(f, "file"),
+ }
+ }
+}
+
+#[derive(Debug, thiserror::Error, miette::Diagnostic)]
+#[error("type error")]
+pub struct Error {
+ #[source]
+ #[diagnostic_source]
+ kind: ErrorKind,
+}
+
+impl Error {
+ const fn null() -> Self {
+ Self {
+ kind: ErrorKind::Null {},
+ }
+ }
+
+ const fn unexpected_param_type(expected: Type, got: Type) -> Self {
+ Self {
+ kind: ErrorKind::UnexpectedParamterType { expected, got },
+ }
+ }
+}
+
+// TODO: source spans
+#[derive(Debug, thiserror::Error, miette::Diagnostic)]
+pub enum ErrorKind {
+ #[error("null (TODO)")]
+ Null {},
+ #[error("parameter type does not match expected type: expected `{expected}` got `{got}`")]
+ UnexpectedParamterType { expected: Type, got: Type },
+
+ #[error("")]
+ Runtime {},
+}
+
+type Result<T, E = Error> = std::result::Result<T, E>;
+
+#[derive(Debug, Clone, PartialEq)]
+pub enum Value {
+ Null,
+ Bool(Bool),
+ Integer(Integer),
+ Decimal(Decimal),
+ String(String),
+ Array(Array),
+ Object(Object),
+ Date(Date),
+ Duration(Duration),
+ Link(Link),
+ File(File),
+}
+
+impl Value {
+ pub const fn as_type(&self) -> Result<Type> {
+ match self {
+ Self::Null => Err(Error::null()),
+ Self::Bool(_) => Ok(Type::Bool),
+ Self::Integer(_) => Ok(Type::Integer),
+ Self::Decimal(_) => Ok(Type::Decimal),
+ Self::String(_) => Ok(Type::String),
+ Self::Array(_) => Ok(Type::Array),
+ Self::Object(_) => Ok(Type::Object),
+ Self::Date(_) => Ok(Type::Date),
+ Self::Duration(_) => Ok(Type::Duration),
+ Self::Link(_) => Ok(Type::Link),
+ Self::File(_) => Ok(Type::File),
+ }
+ }
+
+ pub const fn as_string(&self) -> Result<&String> {
+ let typ = match self {
+ Self::String(str) => return Ok(str),
+ Self::Null => return Err(Error::null()),
+ s => match s.as_type() {
+ Ok(typ) => typ,
+ Err(err) => return Err(err),
+ },
+ };
+
+ Err(Error::unexpected_param_type(Type::String, typ))
+ }
+ #[must_use]
+ const fn is_bool(&self) -> bool {
+ matches!(self, Self::Bool(..))
+ }
+
+ #[must_use]
+ const fn is_integer(&self) -> bool {
+ matches!(self, Self::Integer(..))
+ }
+
+ #[must_use]
+ const fn is_decimal(&self) -> bool {
+ matches!(self, Self::Decimal(..))
+ }
+
+ fn into_bool(self) -> Result<Self> {
+ match self {
+ Self::Null => todo!(),
+ Self::Bool(value) => Ok(Self::Bool(value)),
+ Self::Integer(value) => Ok(Self::Bool(Bool(value.0 != 0))),
+ Self::Decimal(value) => Ok(Self::Bool(Bool(value.0 != 0.0))),
+ _ => todo!(),
+ }
+ }
+
+ fn into_integer(self) -> Result<Self> {
+ match self {
+ Self::Null => todo!(),
+ Self::Bool(value) => Ok(Self::Integer(bast!(value: i64))),
+ Self::Integer(value) => Ok(Self::Integer(value)),
+ Self::Decimal(value) => Ok(Self::Integer(Integer(value.0.round() as i64))),
+ _ => todo!(),
+ }
+ }
+
+ fn into_decimal(self) -> Result<Self> {
+ match self {
+ Self::Null => todo!(),
+ Self::Bool(value) => Ok(Self::Decimal(bast!(value: f64))),
+ Self::Integer(value) => Ok(Self::Decimal(Decimal(value.0 as f64))),
+ Self::Decimal(value) => Ok(Self::Decimal(value)),
+ _ => todo!(),
+ }
+ }
+
+ fn into_string(self) -> Result<Self> {
+ match self {
+ Self::Null => todo!(),
+ Self::Bool(value) => Ok(Self::Bool(value)),
+ Self::Integer(value) => Ok(Self::String(String(format!("{}", value.0)))),
+ Self::Decimal(value) => Ok(Self::String(String(format!("{}", value.0)))),
+ Self::String(value) => Ok(Self::String(value)),
+ _ => todo!(),
+ }
+ }
+}
+
+impl Value {
+ pub fn is_truthy(&self) -> Bool {
+ match self {
+ Self::Null => Bool(false),
+ Self::Bool(bool) => *bool,
+ Self::Integer(v) => Bool(v.0 != 0),
+ Self::Decimal(v) => Bool(v.0 != 0.0),
+ Self::String(v) => Bool(!v.0.trim().is_empty()),
+ Self::Array(_)
+ | Self::Object(_)
+ | Self::Date(_)
+ | Self::Duration(_)
+ | Self::Link(_)
+ | Self::File(_) => Bool(true),
+ }
+ }
+
+ pub fn is_falsy(&self) -> Bool {
+ !self.is_truthy()
+ }
+
+ pub fn is_type(&self) {
+ todo!()
+ }
+
+ pub fn to_string(&self) {
+ todo!()
+ }
+}
+
+pub fn escape_html(html: &str) -> String {
+ String(askama_escape::escape(html, askama_escape::Html).to_string())
+}
+
+pub fn date(date: &String) -> Result<Date> {
+ // Ok(Date(date.0.parse().map_err(|err| Error {
+ // kind: ErrorKind::Runtime {},
+ // })?))
+
+ todo!()
+}
+
+pub fn duration(value: &String) {
+ todo!()
+}
+
+pub fn file() {
+ todo!()
+}
+
+pub fn html(html: &String) {
+ todo!()
+}
+
+pub fn r#if(condition: &Value, true_result: &Value, false_result: Option<&Value>) -> Value {
+ if condition.is_truthy().0 {
+ true_result.clone()
+ } else {
+ false_result.cloned().unwrap_or(Value::Null)
+ }
+}
+
+pub fn image() {
+ todo!()
+}
+
+pub fn icon(value: &String) {
+ todo!()
+}
+
+pub fn link() {
+ todo!()
+}
+
+pub fn list() {
+ todo!()
+}
+
+pub fn max() {
+ todo!()
+}
+
+pub fn min() {
+ todo!()
+}
+
+pub fn now() {
+ todo!()
+}
+
+pub fn number() {
+ todo!()
+}
+
+pub fn today() {
+ todo!()
+}
+
+#[repr(transparent)]
+#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq)]
+pub struct Bool(pub bool);
+
+impl PartialEq<bool> for Bool {
+ fn eq(&self, other: &bool) -> bool {
+ &self.0 == other
+ }
+}
+
+impl std::ops::Not for Bool {
+ type Output = Self;
+
+ fn not(self) -> Self::Output {
+ Self(!self.0)
+ }
+}
+
+#[repr(transparent)]
+#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq)]
+pub struct Integer(pub i64);
+
+impl Integer {
+ pub const fn abs(self) -> Self {
+ Self(self.0.abs())
+ }
+
+ pub const fn ceil(self) -> Self {
+ Self(self.0)
+ }
+
+ pub const fn floor(self) -> Self {
+ Self(self.0)
+ }
+
+ // ???????
+ pub fn is_empty(self) -> Bool {
+ todo!()
+ }
+
+ pub const fn round(self, _digits: Self) -> Self {
+ Self(self.0)
+ }
+
+ pub fn to_fixed(self, _precision: Self) -> String {
+ String(format!("{}", self.0))
+ }
+}
+
+#[repr(transparent)]
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub struct Decimal(pub f64);
+
+impl Decimal {
+ pub const fn abs(self) -> Self {
+ Self(self.0.abs())
+ }
+
+ pub const fn ceil(self) -> Self {
+ Self(self.0.ceil())
+ }
+
+ pub const fn floor(self) -> Self {
+ Self(self.0.floor())
+ }
+
+ // ???????
+ pub fn is_empty(self) -> Bool {
+ todo!()
+ }
+
+ pub const fn round(self, _digits: Integer) -> Self {
+ Self(self.0.round())
+ }
+
+ pub fn to_fixed(self, precision: Integer) -> StdString {
+ format!("{:.*}", precision.0 as usize, self.0)
+ }
+}
+
+#[derive(Debug, Clone, Hash, PartialEq, Eq)]
+pub struct String(pub StdString);
+
+impl String {
+ pub const fn length(&self) -> Integer {
+ Integer(self.0.len() as i64)
+ }
+
+ pub fn contains(&self, value: &Self) -> Bool {
+ Bool(self.0.contains(&value.0))
+ }
+
+ pub fn contains_all(&self, values: &[&Value]) -> Result<Bool> {
+ let mut ret = false;
+
+ for value in values {
+ let cast = value.as_string()?;
+
+ ret &= self.0.contains(&cast.0);
+ }
+
+ Ok(Bool(ret))
+ }
+
+ pub fn contains_any(&self, values: &[&Value]) -> Result<Bool> {
+ for value in values {
+ let cast = value.as_string()?;
+
+ if self.0.contains(&cast.0) {
+ return Ok(Bool(true));
+ }
+ }
+
+ Ok(Bool(false))
+ }
+
+ pub const fn is_empty(&self) -> Bool {
+ Bool(self.0.is_empty())
+ }
+
+ pub fn starts_with(&self, query: &Self) -> Bool {
+ Bool(self.0.starts_with(&query.0))
+ }
+
+ pub fn ends_with(&self, query: &Self) -> Bool {
+ Bool(self.0.ends_with(&query.0))
+ }
+
+ pub fn lower(&self) -> Self {
+ Self(self.0.to_lowercase())
+ }
+
+ pub fn replace(&self, _pattern: (), _replacement: Self) -> Self {
+ todo!()
+ }
+
+ pub fn split(&self, _separator: (), _n: Option<Integer>) -> Vec<Value> {
+ todo!()
+ }
+
+ pub fn repeat(&self, count: Integer) -> Self {
+ let mut buf = StdString::with_capacity((self.0.len() * count.0 as usize) + 8);
+
+ for _ in 0..(count.0 as usize) {
+ buf.push_str(&self.0);
+ }
+
+ Self(buf)
+ }
+
+ pub fn reverse(&self) -> Self {
+ Self(self.0.chars().rev().collect())
+ }
+
+ pub fn slice(&self, start: Integer, end: Option<Integer>) -> Self {
+ if let Some(end) = end {
+ return Self(StdString::from(
+ &self.0[(start.0 as usize)..(end.0 as usize)],
+ ));
+ }
+
+ Self(StdString::from(&self.0[(start.0 as usize)..]))
+ }
+
+ pub fn title(&self) -> Self {
+ todo!()
+ }
+
+ pub fn trim(&self) -> Self {
+ Self(self.0.trim().to_owned())
+ }
+}
+
+#[repr(transparent)]
+#[derive(Debug, Clone, PartialEq)]
+pub struct Array(pub Vec<Value>);
+
+impl Array {
+ pub fn length(&self) {
+ todo!()
+ }
+
+ pub fn contains(&self) {
+ todo!()
+ }
+
+ pub fn contains_all(&self) {
+ todo!()
+ }
+
+ pub fn contains_any(&self) {
+ todo!()
+ }
+
+ pub fn filter(&self) {
+ todo!()
+ }
+
+ pub fn flat(&self) {
+ todo!()
+ }
+
+ pub fn is_empty(&self) {
+ todo!()
+ }
+
+ pub fn join(&self) {
+ todo!()
+ }
+
+ pub fn map(&self) {
+ todo!()
+ }
+
+ pub fn reduce(&self) {
+ todo!()
+ }
+
+ pub fn reverse(&self) {
+ todo!()
+ }
+
+ pub fn slice(&self) {
+ todo!()
+ }
+
+ pub fn sort(&self) {
+ todo!()
+ }
+
+ pub fn unique(&self) {
+ todo!()
+ }
+}
+
+#[repr(transparent)]
+#[derive(Debug, Clone, PartialEq)]
+pub struct Object(pub BTreeMap<(), Value>);
+
+impl Object {
+ pub fn is_empty(&self) {
+ todo!()
+ }
+
+ pub fn keys(&self) {
+ todo!()
+ }
+
+ pub fn values(&self) {
+ todo!()
+ }
+}
+
+#[derive(Debug, Clone, Hash, PartialEq, Eq)]
+pub struct Duration {
+ inner: jiff::SignedDuration,
+}
+
+#[derive(Debug, Clone, Hash, PartialEq, Eq)]
+pub struct Date {
+ inner: jiff::civil::DateTime,
+}
+
+// impl<'js> Date<'js> {
+// pub fn year(&self) {
+// todo!()
+// }
+
+// pub fn month(&self) {
+// todo!()
+// }
+
+// pub fn day(&self) {
+// todo!()
+// }
+
+// pub fn hour(&self) {
+// todo!()
+// }
+
+// pub fn minute(&self) {
+// todo!()
+// }
+
+// pub fn second(&self) {
+// todo!()
+// }
+
+// pub fn millisecond(&self) {
+// todo!()
+// }
+
+// pub fn date(&self) {
+// todo!()
+// }
+
+// pub fn format(&self) {
+// todo!()
+// }
+
+// pub fn time(&self) {
+// todo!()
+// }
+
+// pub fn relative(&self) {
+// todo!()
+// }
+
+// pub fn is_empty(&self) {
+// todo!()
+// }
+// }
+
+#[derive(Debug, Clone, PartialEq)]
+pub struct Link();
+
+impl Link {
+ pub fn as_file(&self) {
+ todo!()
+ }
+
+ pub fn links_to(&self) {
+ todo!()
+ }
+}
+
+#[derive(Debug, Clone, Hash, PartialEq, Eq)]
+pub struct File {
+ path: Utf8PathBuf,
+ creation_time: Date,
+ mondified_time: Date,
+}
+
+impl File {
+ pub fn name(&self) -> &str {
+ self.path.file_name().unwrap_or("")
+ }
+
+ pub fn basename(&self) -> &str {
+ self.path.file_stem().unwrap_or("")
+ }
+
+ pub fn path(&self) {
+ todo!()
+ }
+
+ pub fn folder(&self) -> &str {
+ self.path.parent().map_or("", |path| path.as_str())
+ }
+
+ pub fn extension(&self) {
+ todo!()
+ }
+
+ pub fn size(&self) {
+ todo!()
+ }
+
+ pub fn properties(&self) {
+ todo!()
+ }
+
+ pub fn tags(&self) {
+ todo!()
+ }
+
+ pub fn links(&self) {
+ todo!()
+ }
+
+ pub fn creation_time(&self) {
+ todo!()
+ }
+
+ pub fn modified_time(&self) {
+ todo!()
+ }
+
+ pub fn as_link(&self) {
+ todo!()
+ }
+
+ pub fn has_link(&self) {
+ todo!()
+ }
+
+ pub fn has_property(&self) {
+ todo!()
+ }
+
+ pub fn has_tag(&self) {
+ todo!()
+ }
+
+ pub fn in_folder(&self) {
+ todo!()
+ }
+}
diff --git a/sable-bases/src/lib.rs b/sable-bases/src/lib.rs
new file mode 100644
index 0000000..f655bf5
+#![deny(rust_2018_idioms, unsafe_code)]
+#![warn(
+ absolute_paths_not_starting_with_crate,
+ ambiguous_associated_items,
+ anonymous_parameters,
+ arithmetic_overflow,
+ array_into_iter,
+ asm_sub_register,
+ bad_asm_style,
+ bindings_with_variant_name,
+ break_with_label_and_loop,
+ clashing_extern_declarations,
+ coherence_leak_check,
+ conflicting_repr_hints,
+ confusable_idents,
+ const_evaluatable_unchecked,
+ const_item_mutation,
+ dangling_pointers_from_temporaries,
+ dead_code,
+ deprecated_in_future,
+ deprecated_where_clause_location,
+ deprecated,
+ deref_into_dyn_supertrait,
+ deref_nullptr,
+ drop_bounds,
+ duplicate_macro_attributes,
+ dyn_drop,
+ ellipsis_inclusive_range_patterns,
+ enum_intrinsics_non_enums,
+ explicit_outlives_requirements,
+ exported_private_dependencies,
+ forbidden_lint_groups,
+ function_item_references,
+ future_incompatible,
+ ill_formed_attribute_input,
+ improper_ctypes_definitions,
+ improper_ctypes,
+ incomplete_features,
+ incomplete_include,
+ ineffective_unstable_trait_impl,
+ inline_no_sanitize,
+ invalid_atomic_ordering,
+ invalid_doc_attributes,
+ invalid_type_param_default,
+ invalid_value,
+ irrefutable_let_patterns,
+ keyword_idents,
+ large_assignments,
+ late_bound_lifetime_arguments,
+ legacy_derive_helpers,
+ macro_expanded_macro_exports_accessed_by_absolute_paths,
+ meta_variable_misuse,
+ missing_abi,
+ missing_copy_implementations,
+ missing_debug_implementations,
+ missing_docs,
+ mixed_script_confusables,
+ mutable_transmutes,
+ named_arguments_used_positionally,
+ named_asm_labels,
+ no_mangle_const_items,
+ no_mangle_generic_items,
+ non_ascii_idents,
+ non_camel_case_types,
+ non_fmt_panics,
+ non_shorthand_field_patterns,
+ non_snake_case,
+ non_upper_case_globals,
+ nonstandard_style,
+ noop_method_call,
+ overflowing_literals,
+ overlapping_range_endpoints,
+ path_statements,
+ patterns_in_fns_without_body,
+ proc_macro_derive_resolution_fallback,
+ pub_use_of_private_extern_crate,
+ redundant_semicolons,
+ repr_transparent_external_private_fields,
+ rust_2021_incompatible_closure_captures,
+ rust_2021_incompatible_or_patterns,
+ rust_2021_prefixes_incompatible_syntax,
+ rust_2021_prelude_collisions,
+ semicolon_in_expressions_from_macros,
+ soft_unstable,
+ stable_features,
+ text_direction_codepoint_in_comment,
+ text_direction_codepoint_in_literal,
+ trivial_bounds,
+ trivial_casts,
+ trivial_numeric_casts,
+ type_alias_bounds,
+ tyvar_behind_raw_pointer,
+ uncommon_codepoints,
+ unconditional_panic,
+ unconditional_recursion,
+ unexpected_cfgs,
+ uninhabited_static,
+ unknown_crate_types,
+ unnameable_test_items,
+ unreachable_code,
+ unreachable_patterns,
+ unreachable_pub,
+ unsafe_op_in_unsafe_fn,
+ unstable_features,
+ unstable_name_collisions,
+ unused_allocation,
+ unused_assignments,
+ unused_attributes,
+ unused_braces,
+ unused_comparisons,
+ unused_crate_dependencies,
+ unused_doc_comments,
+ unused_extern_crates,
+ unused_features,
+ unused_import_braces,
+ unused_imports,
+ unused_labels,
+ unused_lifetimes,
+ unused_macro_rules,
+ unused_macros,
+ unused_must_use,
+ unused_mut,
+ unused_parens,
+ unused_qualifications,
+ unused_unsafe,
+ unused_variables,
+ useless_deprecated,
+ while_true
+)]
+#![warn(
+ clippy::all,
+ clippy::await_holding_lock,
+ clippy::char_lit_as_u8,
+ clippy::checked_conversions,
+ clippy::cognitive_complexity,
+ clippy::dbg_macro,
+ clippy::debug_assert_with_mut_call,
+ clippy::disallowed_script_idents,
+ clippy::doc_link_with_quotes,
+ clippy::doc_markdown,
+ clippy::empty_enum,
+ clippy::empty_line_after_outer_attr,
+ clippy::empty_structs_with_brackets,
+ clippy::enum_glob_use,
+ clippy::equatable_if_let,
+ clippy::exit,
+ clippy::expl_impl_clone_on_copy,
+ clippy::explicit_deref_methods,
+ clippy::explicit_into_iter_loop,
+ clippy::fallible_impl_from,
+ clippy::filter_map_next,
+ clippy::flat_map_option,
+ clippy::float_cmp_const,
+ clippy::float_cmp,
+ clippy::float_equality_without_abs,
+ clippy::fn_params_excessive_bools,
+ clippy::fn_to_numeric_cast_any,
+ clippy::from_iter_instead_of_collect,
+ clippy::if_let_mutex,
+ clippy::implicit_clone,
+ clippy::imprecise_flops,
+ clippy::index_refutable_slice,
+ clippy::inefficient_to_string,
+ clippy::invalid_upcast_comparisons,
+ clippy::iter_not_returning_iterator,
+ clippy::large_digit_groups,
+ clippy::large_stack_arrays,
+ clippy::large_types_passed_by_value,
+ clippy::let_unit_value,
+ clippy::linkedlist,
+ clippy::lossy_float_literal,
+ clippy::macro_use_imports,
+ clippy::manual_ok_or,
+ clippy::map_err_ignore,
+ clippy::map_flatten,
+ clippy::map_unwrap_or,
+ clippy::match_same_arms,
+ clippy::match_wild_err_arm,
+ clippy::match_wildcard_for_single_variants,
+ clippy::mem_forget,
+ clippy::missing_const_for_fn,
+ clippy::missing_enforced_import_renames,
+ clippy::missing_errors_doc,
+ clippy::missing_panics_doc,
+ clippy::mut_mut,
+ clippy::mutex_integer,
+ clippy::needless_borrow,
+ clippy::needless_continue,
+ clippy::needless_for_each,
+ clippy::needless_pass_by_value,
+ clippy::negative_feature_names,
+ clippy::nonstandard_macro_braces,
+ clippy::nursery,
+ clippy::option_if_let_else,
+ clippy::option_option,
+ clippy::path_buf_push_overwrite,
+ clippy::pedantic,
+ clippy::print_stderr,
+ clippy::print_stdout,
+ clippy::ptr_as_ptr,
+ clippy::rc_mutex,
+ clippy::ref_option_ref,
+ clippy::rest_pat_in_fully_bound_structs,
+ clippy::same_functions_in_if_condition,
+ clippy::semicolon_if_nothing_returned,
+ clippy::shadow_unrelated,
+ clippy::similar_names,
+ clippy::single_match_else,
+ clippy::string_add_assign,
+ clippy::string_add,
+ clippy::string_lit_as_bytes,
+ clippy::suspicious_operation_groupings,
+ clippy::todo,
+ clippy::trailing_empty_array,
+ clippy::trait_duplication_in_bounds,
+ clippy::trivially_copy_pass_by_ref,
+ clippy::unimplemented,
+ clippy::unnecessary_wraps,
+ clippy::unnested_or_patterns,
+ clippy::unseparated_literal_suffix,
+ clippy::unused_self,
+ clippy::use_debug,
+ clippy::use_self,
+ clippy::used_underscore_binding,
+ clippy::useless_let_if_seq,
+ clippy::useless_transmute,
+ clippy::verbose_file_reads,
+ clippy::wildcard_dependencies,
+ clippy::wildcard_imports,
+ clippy::zero_sized_map_values
+)]
+
+//!
+//!
+//!
+
+pub mod filter;
+
+pub mod document;
+pub mod engine;
+
+use crate::document::Base;
+
+#[derive(Debug, thiserror::Error)]
+pub enum ParseError {
+ #[error("failed to deserialize base yaml")]
+ Yaml(#[from] serde_yaml::Error),
+}
+
+pub fn from_reader<R>(rdr: R) -> Result<Base, ParseError>
+where
+ R: std::io::Read,
+{
+ serde_yaml::from_reader::<_, Base>(rdr).map_err(ParseError::Yaml)
+}
+
+pub fn from_str(s: &str) -> Result<Base, ParseError> {
+ serde_yaml::from_str::<Base>(s).map_err(ParseError::Yaml)
+}
+
+pub fn from_slice<'de>(v: &'de [u8]) -> Result<Base, ParseError> {
+ serde_yaml::from_slice::<Base>(v).map_err(ParseError::Yaml)
+}
diff --git a/sable-canvas/Cargo.toml b/sable-canvas/Cargo.toml
new file mode 100644
index 0000000..655c9a1
+[package]
+name = "sable-canvas"
+version = "0.1.0"
+edition = "2024"
+
+workspace = ".."
+
+[dependencies]
+hex_color = { version = "3.0.0", features = ["serde"] }
+serde.workspace = true
+serde_json.workspace = true
+thiserror.workspace = true
+url = { version = "2.5.7", features = ["serde"] }
diff --git a/sable-canvas/src/canvas.rs b/sable-canvas/src/canvas.rs
new file mode 100644
index 0000000..7d0bc40
+use std::{
+ collections::HashMap,
+ fmt::{Display, Formatter},
+ str::FromStr,
+};
+
+use serde::{Deserialize as _, Deserializer, Serialize as _, Serializer};
+
+use crate::{
+ EdgeId, NodeId,
+ edge::Edge,
+ id::EmptyId,
+ node::{GenericNodeInfo, Node},
+};
+
+/// Errors that can occur when loading/saving or modifing a [`Canvas`].
+#[derive(Debug, thiserror::Error)]
+pub enum CanvasError {
+ /// A [`Node`] with that ID already exists in the [`Canvas`].
+ #[error("Node {0} already exists")]
+ NodeExists(NodeId),
+ /// A [`Edge`] with that ID already exists in the [`Canvas`].
+ #[error("Edge {0} already exists")]
+ EdgeExists(EdgeId),
+ /// A [`Node`] does not exists with that ID and the [`Edge`] is unable to connect to it as a result.
+ #[error("Node {0} does not exist")]
+ NodeNotExists(NodeId),
+ /// Failed to parse the raw JSON to the [`Canvas`] struct.
+ #[error(transparent)]
+ ParseError(#[from] serde_json::Error),
+ /// The [`Node`] or [`Edge`] ID supplied is empty.
+ ///
+ /// This isn't *not* allowed by the spec, but there should never be a reason where this is the case.
+ #[error(transparent)]
+ EmptyId(#[from] EmptyId),
+}
+
+/// Main struct for the canvas
+#[derive(Debug, Default, serde::Serialize, serde::Deserialize)]
+pub struct Canvas {
+ #[serde(
+ serialize_with = "serialize_as_vec_node",
+ deserialize_with = "deserialize_as_map_node"
+ )]
+ #[serde(skip_serializing_if = "HashMap::is_empty", default)]
+ nodes: HashMap<NodeId, Node>,
+ #[serde(
+ serialize_with = "serialize_as_vec_edge",
+ deserialize_with = "deserialize_as_map_edge"
+ )]
+ #[serde(skip_serializing_if = "HashMap::is_empty", default)]
+ edges: HashMap<EdgeId, Edge>,
+}
+fn serialize_as_vec_node<S>(data: &HashMap<NodeId, Node>, serializer: S) -> Result<S::Ok, S::Error>
+where
+ S: Serializer,
+{
+ let vec: Vec<&Node> = data.values().collect();
+ vec.serialize(serializer)
+}
+
+fn deserialize_as_map_node<'de, D>(deserializer: D) -> Result<HashMap<NodeId, Node>, D::Error>
+where
+ D: Deserializer<'de>,
+{
+ let vec: Vec<Node> = Vec::deserialize(deserializer)?;
+ let map: HashMap<_, _> = vec
+ .into_iter()
+ .map(|node| (node.id().clone(), node))
+ .collect();
+ Ok(map)
+}
+
+fn serialize_as_vec_edge<S>(data: &HashMap<EdgeId, Edge>, serializer: S) -> Result<S::Ok, S::Error>
+where
+ S: Serializer,
+{
+ let vec: Vec<&Edge> = data.values().collect();
+ vec.serialize(serializer)
+}
+
+fn deserialize_as_map_edge<'de, D>(deserializer: D) -> Result<HashMap<EdgeId, Edge>, D::Error>
+where
+ D: Deserializer<'de>,
+{
+ let vec: Vec<Edge> = Vec::deserialize(deserializer)?;
+ let map: HashMap<_, _> = vec
+ .into_iter()
+ .map(|node| (node.id().clone(), node))
+ .collect();
+ Ok(map)
+}
+
+impl Canvas {
+ /// Add a node to the canvas.
+ ///
+ /// # Errors
+ ///
+ /// This is fail if the node already exists in the canvas.
+ pub fn add_node(&mut self, node: Node) -> Result<(), CanvasError> {
+ if self.nodes.contains_key(node.id()) {
+ return Err(CanvasError::NodeExists(node.id().clone()));
+ }
+ self.nodes.insert(node.id().clone(), node);
+ Ok(())
+ }
+
+ /// Add a edge to the canvas.
+ ///
+ /// # Errors
+ ///
+ /// This is fail if the edge already exists in the canvas,
+ /// or the start and end nodes don't exist.
+ pub fn add_edge(&mut self, edge: Edge) -> Result<(), CanvasError> {
+ if self.edges.contains_key(edge.id()) {
+ return Err(CanvasError::EdgeExists(edge.id().clone()));
+ }
+
+ if !self.nodes.contains_key(edge.from_node()) {
+ return Err(CanvasError::NodeNotExists(edge.from_node().clone()));
+ }
+
+ if !self.nodes.contains_key(edge.to_node()) {
+ return Err(CanvasError::NodeNotExists(edge.to_node().clone()));
+ }
+
+ self.edges.insert(edge.id().clone(), edge);
+ Ok(())
+ }
+
+ /// Returns a mutable reference of a specific [`Node`] from this canvas.
+ pub fn get_node(&mut self, id: &NodeId) -> Option<&mut Node> {
+ self.nodes.get_mut(id)
+ }
+
+ /// Returns a mutable reference of a specific [`Edge`] from this canvas.
+ pub fn get_edge(&mut self, id: &EdgeId) -> Option<&mut Edge> {
+ self.edges.get_mut(id)
+ }
+
+ /// Get a reference to all the [`Node`]s of this canvas.
+ #[must_use]
+ pub const fn get_nodes(&self) -> &HashMap<NodeId, Node> {
+ &self.nodes
+ }
+
+ /// Get a mutable reference to all the [`Node`]s of this canvas.
+ pub const fn get_mut_nodes(&mut self) -> &mut HashMap<NodeId, Node> {
+ &mut self.nodes
+ }
+
+ /// Get a reference to all the [`Edge`]s of this canvas.
+ #[must_use]
+ pub const fn get_edges(&self) -> &HashMap<EdgeId, Edge> {
+ &self.edges
+ }
+
+ /// Get a mutable reference to all the [`Edge`]s of this canvas.
+ pub const fn get_mut_edges(&mut self) -> &mut HashMap<EdgeId, Edge> {
+ &mut self.edges
+ }
+}
+
+impl FromStr for Canvas {
+ type Err = CanvasError;
+
+ fn from_str(s: &str) -> Result<Self, Self::Err> {
+ Ok(serde_json::from_str(s)?)
+ }
+}
+
+impl Display for Canvas {
+ fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
+ write!(f, "{}", serde_json::to_string(self).unwrap())
+ }
+}
diff --git a/sable-canvas/src/color.rs b/sable-canvas/src/color.rs
new file mode 100644
index 0000000..948e7cc
+pub use hex_color::HexColor;
+
+/// A preset color.
+#[allow(missing_docs)]
+#[derive(
+ Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize,
+)]
+pub enum PresetColor {
+ #[serde(rename = "1")]
+ Red = 1,
+ #[serde(rename = "2")]
+ Orange = 2,
+ #[serde(rename = "3")]
+ Yellow = 3,
+ #[serde(rename = "4")]
+ Green = 4,
+ #[serde(rename = "5")]
+ Cyan = 5,
+ #[serde(rename = "6")]
+ Purple = 6,
+}
+
+/// The color of a node or edge.
+#[derive(
+ Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize,
+)]
+#[serde(untagged)]
+pub enum Color {
+ /// The color is one of the preset colors.
+ Preset(PresetColor),
+ /// The color is a hexadecimal color.
+ Color(HexColor),
+}
+
+impl From<PresetColor> for Color {
+ fn from(value: PresetColor) -> Self {
+ Self::Preset(value)
+ }
+}
+
+impl From<HexColor> for Color {
+ fn from(value: HexColor) -> Self {
+ Self::Color(value)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn preset_deser() {
+ let preset: PresetColor = serde_json::from_str("\"1\"").unwrap();
+ assert_eq!(preset, PresetColor::Red);
+ }
+
+ #[test]
+ fn color_preset_deser() {
+ let color: Color = serde_json::from_str("\"2\"").unwrap();
+ assert_eq!(color, Color::Preset(PresetColor::Orange));
+ }
+
+ #[test]
+ fn color_rgb_deser() {
+ let color: Color = serde_json::from_str("\"#FF0000\"").unwrap();
+ assert_eq!(color, Color::Color(HexColor::rgb(255, 0, 0)));
+ }
+
+ #[test]
+ fn color_ser() {
+ assert_eq!(
+ serde_json::to_string(&Color::Preset(PresetColor::Yellow)).unwrap(),
+ "\"3\""
+ );
+ assert_eq!(
+ serde_json::to_string(&Color::Color(HexColor::rgb(255, 0, 0))).unwrap(),
+ "\"#FF0000\""
+ );
+ }
+}
diff --git a/sable-canvas/src/edge.rs b/sable-canvas/src/edge.rs
new file mode 100644
index 0000000..9104adf
+use crate::{EdgeId, NodeId, color::Color};
+
+/// The connection information of the start or end of an [`Edge`].
+pub type Terminus = (NodeId, Option<Side>, Option<End>);
+
+/// A connection between two [`Node`]s.
+///
+/// [`Node`]: crate::Node
+#[derive(Debug, serde::Serialize, serde::Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct Edge {
+ /// The unique ID of this [`Edge`].
+ pub id: EdgeId,
+ /// The [`Node`] this [`Edge`] starts at.
+ ///
+ /// [`Node`]: crate::Node
+ pub from_node: NodeId,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ from_side: Option<Side>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ from_end: Option<End>,
+ /// The [`Node`] this [`Edge`] ends at.
+ ///
+ /// [`Node`]: crate::Node
+ pub to_node: NodeId,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ to_side: Option<Side>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ to_end: Option<End>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ color: Option<Color>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ label: Option<String>,
+}
+
+impl Edge {
+ /// Creates a new [`Edge`].
+ #[allow(clippy::too_many_arguments)]
+ #[must_use]
+ pub const fn new(
+ id: EdgeId,
+ from_node: NodeId,
+ from_side: Option<Side>,
+ from_end: Option<End>,
+ to_node: NodeId,
+ to_side: Option<Side>,
+ to_end: Option<End>,
+ color: Option<Color>,
+ label: Option<String>,
+ ) -> Self {
+ Self {
+ id,
+ from_node,
+ from_side,
+ from_end,
+ to_node,
+ to_side,
+ to_end,
+ color,
+ label,
+ }
+ }
+
+ /// Returns a reference to the id of this [`Edge`].
+ #[must_use]
+ pub const fn id(&self) -> &EdgeId {
+ &self.id
+ }
+
+ /// Returns the sources's [`NodeId`] of this [`Edge`].
+ #[must_use]
+ pub const fn from_node(&self) -> &NodeId {
+ &self.from_node
+ }
+
+ /// Returns the sources's [`Side`] of this [`Edge`].
+ #[must_use]
+ pub const fn from_side(&self) -> Option<&Side> {
+ self.from_side.as_ref()
+ }
+
+ /// Returns the sources's [`End`] of this [`Edge`].
+ #[must_use]
+ pub const fn from_end(&self) -> Option<&End> {
+ self.from_end.as_ref()
+ }
+
+ /// Returns the target's [`NodeId`] of this [`Edge`].
+ #[must_use]
+ pub const fn to_node(&self) -> &NodeId {
+ &self.to_node
+ }
+
+ /// Returns the target's [`Side`] of this [`Edge`].
+ #[must_use]
+ pub const fn to_side(&self) -> Option<&Side> {
+ self.to_side.as_ref()
+ }
+
+ /// Returns the target's [`End`] of this [`Edge`].
+ #[must_use]
+ pub const fn to_end(&self) -> Option<&End> {
+ self.to_end.as_ref()
+ }
+
+ /// Returns the color of this [`Edge`].
+ #[must_use]
+ pub const fn color(&self) -> Option<&Color> {
+ self.color.as_ref()
+ }
+
+ /// Returns the label of this [`Edge`].
+ #[must_use]
+ pub const fn label(&self) -> Option<&String> {
+ self.label.as_ref()
+ }
+
+ /// Sets the color of this [`Edge`].
+ pub const fn set_color(&mut self, color: Color) -> &mut Self {
+ self.color = Some(color);
+ self
+ }
+
+ /// Remove color of this [`Edge`].
+ pub fn remove_color(&mut self) -> Option<Color> {
+ std::mem::take(&mut self.color)
+ }
+
+ /// Sets the label of this [`Edge`].
+ pub fn set_label(&mut self, label: String) -> &mut Self {
+ self.label = Some(label);
+ self
+ }
+
+ /// Removes the label from this [`Edge`].
+ pub fn remove_label(&mut self) -> Option<String> {
+ std::mem::take(&mut self.label)
+ }
+
+ /// Set the start connection of this [`Edge`], returning the old connection information.
+ pub const fn set_from(
+ &mut self,
+ node: NodeId,
+ side: Option<Side>,
+ end: Option<End>,
+ ) -> Terminus {
+ (
+ std::mem::replace(&mut self.from_node, node),
+ std::mem::replace(&mut self.from_side, side),
+ std::mem::replace(&mut self.from_end, end),
+ )
+ }
+
+ /// Set the end connection of this [`Edge`], returning the old connection information.
+ pub const fn set_to(&mut self, node: NodeId, side: Option<Side>, end: Option<End>) -> Terminus {
+ (
+ std::mem::replace(&mut self.to_node, node),
+ std::mem::replace(&mut self.to_side, side),
+ std::mem::replace(&mut self.to_end, end),
+ )
+ }
+}
+
+/// The side where the [`Edge`] conntects to the [`Node`].
+///
+/// [`Node`]: crate::Node
+#[allow(missing_docs)]
+#[derive(
+ Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize,
+)]
+#[serde(rename_all = "camelCase")]
+pub enum Side {
+ Top,
+ Left,
+ Right,
+ Bottom,
+}
+
+/// The shape of the [`Edge`] connection from [`Edge`] to [`Node`].
+///
+/// [`Node`]: crate::Node
+#[allow(missing_docs)]
+#[derive(
+ Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize,
+)]
+#[serde(rename_all = "camelCase")]
+pub enum End {
+ None,
+ Arrow,
+}
diff --git a/sable-canvas/src/id.rs b/sable-canvas/src/id.rs
new file mode 100644
index 0000000..d33da2c
+/// An empty ID of a [`Node`] or [`Edge`].
+///
+/// [`Edge`]: crate::Edge
+/// [`Node`]: crate::Node
+#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, thiserror::Error)]
+#[error("ID is empty")]
+pub struct EmptyId;
+
+/// The unique ID of an [`Node`].
+///
+/// [`Node`]: crate::Node
+#[derive(
+ Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize,
+)]
+#[repr(transparent)]
+#[serde(transparent)]
+pub struct NodeId(pub(self) String);
+
+impl NodeId {
+ /// Returns the inner [`String`] representation.
+ #[must_use]
+ pub fn into_inner(self) -> String {
+ self.0
+ }
+
+ /// Returns a reference to the inner [`String`] representation.
+ #[must_use]
+ pub const fn as_str(&self) -> &str {
+ self.0.as_str()
+ }
+}
+
+impl std::fmt::Display for NodeId {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ self.0.fmt(f)
+ }
+}
+
+impl std::str::FromStr for NodeId {
+ type Err = EmptyId;
+
+ fn from_str(value: &str) -> Result<Self, Self::Err> {
+ if value.is_empty() {
+ return Err(EmptyId);
+ }
+ Ok(Self(value.to_string()))
+ }
+}
+
+impl TryFrom<String> for NodeId {
+ type Error = EmptyId;
+
+ fn try_from(value: String) -> Result<Self, Self::Error> {
+ if value.is_empty() {
+ return Err(EmptyId);
+ }
+ Ok(Self(value))
+ }
+}
+
+/// The unique ID of an [`Edge`].
+///
+/// [`Edge`]: crate::Edge
+#[derive(
+ Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize,
+)]
+#[repr(transparent)]
+#[serde(transparent)]
+pub struct EdgeId(pub(self) String);
+
+impl EdgeId {
+ /// Returns the inner [`String`] representation.
+ #[must_use]
+ pub fn into_inner(self) -> String {
+ self.0
+ }
+
+ /// Returns a reference to the inner [`String`] representation.
+ #[must_use]
+ pub const fn as_str(&self) -> &str {
+ self.0.as_str()
+ }
+}
+
+impl std::fmt::Display for EdgeId {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ self.0.fmt(f)
+ }
+}
+
+impl std::str::FromStr for EdgeId {
+ type Err = EmptyId;
+
+ fn from_str(value: &str) -> Result<Self, Self::Err> {
+ if value.is_empty() {
+ return Err(EmptyId);
+ }
+ Ok(Self(value.to_string()))
+ }
+}
+
+impl TryFrom<String> for EdgeId {
+ type Error = EmptyId;
+
+ fn try_from(value: String) -> Result<Self, Self::Error> {
+ if value.is_empty() {
+ return Err(EmptyId);
+ }
+ Ok(Self(value))
+ }
+}
diff --git a/sable-canvas/src/lib.rs b/sable-canvas/src/lib.rs
new file mode 100644
index 0000000..c2f9fb6
+#![deny(rust_2018_idioms, unsafe_code)]
+#![warn(
+ absolute_paths_not_starting_with_crate,
+ ambiguous_associated_items,
+ anonymous_parameters,
+ arithmetic_overflow,
+ array_into_iter,
+ asm_sub_register,
+ bad_asm_style,
+ bindings_with_variant_name,
+ break_with_label_and_loop,
+ clashing_extern_declarations,
+ coherence_leak_check,
+ conflicting_repr_hints,
+ confusable_idents,
+ const_evaluatable_unchecked,
+ const_item_mutation,
+ dangling_pointers_from_temporaries,
+ dead_code,
+ deprecated_in_future,
+ deprecated_where_clause_location,
+ deprecated,
+ deref_into_dyn_supertrait,
+ deref_nullptr,
+ drop_bounds,
+ duplicate_macro_attributes,
+ dyn_drop,
+ ellipsis_inclusive_range_patterns,
+ enum_intrinsics_non_enums,
+ explicit_outlives_requirements,
+ exported_private_dependencies,
+ forbidden_lint_groups,
+ function_item_references,
+ future_incompatible,
+ ill_formed_attribute_input,
+ improper_ctypes_definitions,
+ improper_ctypes,
+ incomplete_features,
+ incomplete_include,
+ ineffective_unstable_trait_impl,
+ inline_no_sanitize,
+ invalid_atomic_ordering,
+ invalid_doc_attributes,
+ invalid_type_param_default,
+ invalid_value,
+ irrefutable_let_patterns,
+ keyword_idents,
+ large_assignments,
+ late_bound_lifetime_arguments,
+ legacy_derive_helpers,
+ macro_expanded_macro_exports_accessed_by_absolute_paths,
+ meta_variable_misuse,
+ missing_abi,
+ missing_copy_implementations,
+ missing_debug_implementations,
+ missing_docs,
+ mixed_script_confusables,
+ mutable_transmutes,
+ named_arguments_used_positionally,
+ named_asm_labels,
+ no_mangle_const_items,
+ no_mangle_generic_items,
+ non_ascii_idents,
+ non_camel_case_types,
+ non_fmt_panics,
+ non_shorthand_field_patterns,
+ non_snake_case,
+ non_upper_case_globals,
+ nonstandard_style,
+ noop_method_call,
+ overflowing_literals,
+ overlapping_range_endpoints,
+ path_statements,
+ patterns_in_fns_without_body,
+ proc_macro_derive_resolution_fallback,
+ pub_use_of_private_extern_crate,
+ redundant_semicolons,
+ repr_transparent_external_private_fields,
+ rust_2021_incompatible_closure_captures,
+ rust_2021_incompatible_or_patterns,
+ rust_2021_prefixes_incompatible_syntax,
+ rust_2021_prelude_collisions,
+ semicolon_in_expressions_from_macros,
+ soft_unstable,
+ stable_features,
+ text_direction_codepoint_in_comment,
+ text_direction_codepoint_in_literal,
+ trivial_bounds,
+ trivial_casts,
+ trivial_numeric_casts,
+ type_alias_bounds,
+ tyvar_behind_raw_pointer,
+ uncommon_codepoints,
+ unconditional_panic,
+ unconditional_recursion,
+ unexpected_cfgs,
+ uninhabited_static,
+ unknown_crate_types,
+ unnameable_test_items,
+ unreachable_code,
+ unreachable_patterns,
+ unreachable_pub,
+ unsafe_op_in_unsafe_fn,
+ unstable_features,
+ unstable_name_collisions,
+ unused_allocation,
+ unused_assignments,
+ unused_attributes,
+ unused_braces,
+ unused_comparisons,
+ unused_crate_dependencies,
+ unused_doc_comments,
+ unused_extern_crates,
+ unused_features,
+ unused_import_braces,
+ unused_imports,
+ unused_labels,
+ unused_lifetimes,
+ unused_macro_rules,
+ unused_macros,
+ unused_must_use,
+ unused_mut,
+ unused_parens,
+ unused_qualifications,
+ unused_unsafe,
+ unused_variables,
+ useless_deprecated,
+ while_true
+)]
+#![warn(
+ clippy::all,
+ clippy::await_holding_lock,
+ clippy::char_lit_as_u8,
+ clippy::checked_conversions,
+ clippy::cognitive_complexity,
+ clippy::dbg_macro,
+ clippy::debug_assert_with_mut_call,
+ clippy::disallowed_script_idents,
+ clippy::doc_link_with_quotes,
+ clippy::doc_markdown,
+ clippy::empty_enum,
+ clippy::empty_line_after_outer_attr,
+ clippy::empty_structs_with_brackets,
+ clippy::enum_glob_use,
+ clippy::equatable_if_let,
+ clippy::exit,
+ clippy::expl_impl_clone_on_copy,
+ clippy::explicit_deref_methods,
+ clippy::explicit_into_iter_loop,
+ clippy::fallible_impl_from,
+ clippy::filter_map_next,
+ clippy::flat_map_option,
+ clippy::float_cmp_const,
+ clippy::float_cmp,
+ clippy::float_equality_without_abs,
+ clippy::fn_params_excessive_bools,
+ clippy::fn_to_numeric_cast_any,
+ clippy::from_iter_instead_of_collect,
+ clippy::if_let_mutex,
+ clippy::implicit_clone,
+ clippy::imprecise_flops,
+ clippy::index_refutable_slice,
+ clippy::inefficient_to_string,
+ clippy::invalid_upcast_comparisons,
+ clippy::iter_not_returning_iterator,
+ clippy::large_digit_groups,
+ clippy::large_stack_arrays,
+ clippy::large_types_passed_by_value,
+ clippy::let_unit_value,
+ clippy::linkedlist,
+ clippy::lossy_float_literal,
+ clippy::macro_use_imports,
+ clippy::manual_ok_or,
+ clippy::map_err_ignore,
+ clippy::map_flatten,
+ clippy::map_unwrap_or,
+ clippy::match_same_arms,
+ clippy::match_wild_err_arm,
+ clippy::match_wildcard_for_single_variants,
+ clippy::mem_forget,
+ clippy::missing_const_for_fn,
+ clippy::missing_enforced_import_renames,
+ clippy::missing_errors_doc,
+ clippy::missing_panics_doc,
+ clippy::mut_mut,
+ clippy::mutex_integer,
+ clippy::needless_borrow,
+ clippy::needless_continue,
+ clippy::needless_for_each,
+ clippy::needless_pass_by_value,
+ clippy::negative_feature_names,
+ clippy::nonstandard_macro_braces,
+ clippy::nursery,
+ clippy::option_if_let_else,
+ clippy::option_option,
+ clippy::path_buf_push_overwrite,
+ clippy::pedantic,
+ clippy::print_stderr,
+ clippy::print_stdout,
+ clippy::ptr_as_ptr,
+ clippy::rc_mutex,
+ clippy::ref_option_ref,
+ clippy::rest_pat_in_fully_bound_structs,
+ clippy::same_functions_in_if_condition,
+ clippy::semicolon_if_nothing_returned,
+ clippy::shadow_unrelated,
+ clippy::similar_names,
+ clippy::single_match_else,
+ clippy::string_add_assign,
+ clippy::string_add,
+ clippy::string_lit_as_bytes,
+ clippy::suspicious_operation_groupings,
+ clippy::todo,
+ clippy::trailing_empty_array,
+ clippy::trait_duplication_in_bounds,
+ clippy::trivially_copy_pass_by_ref,
+ clippy::unimplemented,
+ clippy::unnecessary_wraps,
+ clippy::unnested_or_patterns,
+ clippy::unseparated_literal_suffix,
+ clippy::unused_self,
+ clippy::use_debug,
+ clippy::use_self,
+ clippy::used_underscore_binding,
+ clippy::useless_let_if_seq,
+ clippy::useless_transmute,
+ clippy::verbose_file_reads,
+ clippy::wildcard_dependencies,
+ clippy::wildcard_imports,
+ clippy::zero_sized_map_values
+)]
+
+//! # sable-canvas
+//!
+//! `sable-canvas` is a library for creating and manipulating JSON objects representing a canvas.
+//!
+//! Specification source: <https://jsoncanvas.org/>
+//!
+//! ## Example
+//!
+//! ```
+//! use sable_canvas::Canvas;
+//! let s: String = "{\"nodes\":[{\"id\":\"id7\",\"x\":0,\"y\":0,\"width\":100,\"height\":100,\"background\":\"path/to/image.png\",\"type\":\"group\"},{\"id\":\"id5\",\"x\":0,\"y\":0,\"width\":100,\"height\":100,\"color\":\"#ff0000\",\"label\":\"Label\",\"type\":\"group\"},{\"id\":\"id2\",\"x\":0,\"y\":0,\"width\":100,\"height\":100,\"color\":\"1\",\"file\":\"dir/to/path/file.png\",\"type\":\"file\"},{\"id\":\"id4\",\"x\":0,\"y\":0,\"width\":100,\"height\":100,\"color\":\"1\",\"url\":\"https://www.google.com\",\"type\":\"link\"},{\"id\":\"id6\",\"x\":0,\"y\":0,\"width\":100,\"height\":100,\"type\":\"group\"},{\"id\":\"id3\",\"x\":0,\"y\":0,\"width\":100,\"height\":100,\"color\":\"1\",\"file\":\"dir/to/path/file.png\",\"subpath\":\"#here\",\"type\":\"file\"},{\"id\":\"id8\",\"x\":0,\"y\":0,\"width\":100,\"height\":100,\"background\":\"path/to/image.png\",\"backgroundStyle\":\"cover\",\"type\":\"group\"},{\"id\":\"id\",\"x\":0,\"y\":0,\"width\":100,\"height\":100,\"color\":\"1\",\"text\":\"Test\",\"type\":\"text\"}],\"edges\":[{\"id\":\"edge2\",\"fromNode\":\"node3\",\"toNode\":\"node4\",\"color\":\"5\",\"label\":\"edge label\",\"toSide\":\"left\",\"toEnd\":\"arrow\"},{\"id\":\"edge1\",\"fromNode\":\"node1\",\"toNode\":\"node2\",\"toSide\":\"left\",\"toEnd\":\"arrow\"}]}".to_string();
+//! let canvas: Canvas = s.parse().unwrap();
+//!
+//! let _s = canvas.to_string();
+//! ```
+//!
+//! ## Complete example
+//!
+//! ```rust
+//! use std::path::PathBuf;
+//!
+//! use sable_canvas::{
+//! Canvas,
+//! Color, HexColor, PresetColor,
+//! Edge, End, Side,
+//! Background, BackgroundStyle, FileNode, GroupNode, LinkNode, Node, TextNode,
+//! };
+//! use url::Url;
+//!
+//! // Color
+//! let color1 = Color::Preset(PresetColor::Red);
+//! let color2 = Color::Color(HexColor::parse("#ff0000").unwrap());
+//!
+//! let serialized_color1 = serde_json::to_string(&color1).unwrap();
+//! let serialized_color2 = serde_json::to_string(&color2).unwrap();
+//!
+//! println!("serialized1 = {}", serialized_color1);
+//! println!("serialized2 = {}", serialized_color2);
+//!
+//! // Text Node
+//! let node1: Node = Node::Text(TextNode::new(
+//! "id".parse().unwrap(),
+//! 0,
+//! 0,
+//! 100,
+//! 100,
+//! Some(Color::Preset(PresetColor::Red)),
+//! "This is a test".to_string(),
+//! ));
+//!
+//! // File Node
+//! let node2: Node = Node::File(FileNode::new(
+//! "id2".parse().unwrap(),
+//! 0,
+//! 0,
+//! 100,
+//! 100,
+//! Some(Color::Preset(PresetColor::Red)),
+//! PathBuf::from("dir/to/path/file.png"),
+//! None,
+//! ));
+//! let node3: Node = Node::File(FileNode::new(
+//! "id3".parse().unwrap(),
+//! 0,
+//! 0,
+//! 100,
+//! 100,
+//! Some(color1),
+//! PathBuf::from("dir/to/path/file.png"),
+//! Some("#here".parse().unwrap()),
+//! ));
+//!
+//! // Link Node
+//! let node4: Node = Node::Link(LinkNode::new(
+//! "id4".parse().unwrap(),
+//! 0,
+//! 0,
+//! 100,
+//! 100,
+//! Some(Color::Preset(PresetColor::Red)),
+//! Url::parse("https://julienduroure.com").unwrap(),
+//! ));
+//!
+//! // Group Node
+//! let node5: Node = Node::Group(GroupNode::new(
+//! "id5".parse().unwrap(),
+//! 0,
+//! 0,
+//! 100,
+//! 100,
+//! Some(color2),
+//! Some("Label".to_string()),
+//! None,
+//! ));
+//! let node6: Node = Node::Group(GroupNode::new(
+//! "id6".parse().unwrap(),
+//! 0,
+//! 0,
+//! 100,
+//! 100,
+//! None,
+//! None,
+//! None,
+//! ));
+//! let node7: Node = Node::Group(GroupNode::new(
+//! "id7".parse().unwrap(),
+//! 0,
+//! 0,
+//! 100,
+//! 100,
+//! None,
+//! None,
+//! Some(Background::new(PathBuf::from("path/to/image.png"), None)),
+//! ));
+//! let node8: Node = Node::Group(GroupNode::new(
+//! "id8".parse().unwrap(),
+//! 0,
+//! 0,
+//! 100,
+//! 100,
+//! None,
+//! None,
+//! Some(Background::new(
+//! PathBuf::from("path/to/image.png"),
+//! Some(BackgroundStyle::Cover),
+//! )),
+//! ));
+//!
+//! let serialized_node1: String = serde_json::to_string(&node1).unwrap();
+//! let serialized_node2 = serde_json::to_string(&node2).unwrap();
+//! let serialized_node3 = serde_json::to_string(&node3).unwrap();
+//! let serialized_node4 = serde_json::to_string(&node4).unwrap();
+//! let serialized_node5 = serde_json::to_string(&node5).unwrap();
+//! let serialized_node6 = serde_json::to_string(&node6).unwrap();
+//! let serialized_node7 = serde_json::to_string(&node7).unwrap();
+//! let serialized_node8 = serde_json::to_string(&node8).unwrap();
+//!
+//! println!("serialized node 1= {}", serialized_node1);
+//! println!("serialized node 2= {}", serialized_node2);
+//! println!("serialized node 3= {}", serialized_node3);
+//! println!("serialized node 4= {}", serialized_node4);
+//! println!("serialized node 5= {}", serialized_node5);
+//! println!("serialized node 6= {}", serialized_node6);
+//! println!("serialized node 7= {}", serialized_node7);
+//! println!("serialized node 8= {}", serialized_node8);
+//!
+//! // Edge
+//! let edge1 = Edge::new(
+//! "edge1".parse().unwrap(),
+//! "id".parse().unwrap(),
+//! None,
+//! None,
+//! "id2".parse().unwrap(),
+//! Some(Side::Left),
+//! Some(End::Arrow),
+//! None,
+//! None,
+//! );
+//! let edge2 = Edge::new(
+//! "edge2".parse().unwrap(),
+//! "id3".parse().unwrap(),
+//! None,
+//! None,
+//! "id4".parse().unwrap(),
+//! Some(Side::Left),
+//! Some(End::Arrow),
+//! Some(Color::Preset(PresetColor::Cyan)),
+//! Some("edge label".to_string()),
+//! );
+//!
+//! let serialized_edge1 = serde_json::to_string(&edge1).unwrap();
+//! let serialized_edge2 = serde_json::to_string(&edge2).unwrap();
+//!
+//! println!("serialized edge 1= {}", serialized_edge1);
+//! println!("serialized edge 2= {}", serialized_edge2);
+//!
+//! // JSON Canvas
+//! let mut canvas = Canvas::default();
+//!
+//! let empty_canvas = canvas.to_string();
+//! println!("empty canvas = {}", empty_canvas);
+//! canvas = empty_canvas.parse().unwrap();
+//!
+//! canvas.add_node(node1).unwrap();
+//! canvas.add_node(node2).unwrap();
+//! canvas.add_node(node3).unwrap();
+//! canvas.add_node(node4).unwrap();
+//! canvas.add_node(node5).unwrap();
+//! canvas.add_node(node6).unwrap();
+//! canvas.add_node(node7).unwrap();
+//! canvas.add_node(node8).unwrap();
+//!
+//! canvas.add_edge(edge1).unwrap();
+//! canvas.add_edge(edge2).unwrap();
+//!
+//! let serialized_canvas = canvas.to_string();
+//!
+//! println!("serialized canvas = {}", serialized_canvas);
+//!
+//! let jsoncanvas_deserialized: Canvas = serialized_canvas.parse().unwrap();
+//! println!("deserialized canvas = {:?}", jsoncanvas_deserialized);
+//! ```
+//!
+//! ## Available structs
+//!
+//! ```
+//! use sable_canvas::{
+//! Canvas,
+//! Color, HexColor, PresetColor,
+//! Edge, End, Side,
+//! Background, BackgroundStyle, FileNode, GroupNode, LinkNode, Node, TextNode,
+//! };
+//! ```
+
+mod canvas;
+mod color;
+mod edge;
+mod id;
+mod node;
+
+pub use crate::{
+ canvas::{Canvas, CanvasError},
+ color::{Color, HexColor, PresetColor},
+ edge::{Edge, End, Side, Terminus},
+ id::{EdgeId, EmptyId, NodeId},
+ node::{
+ Background, BackgroundStyle, FileNode, GenericNode, GenericNodeInfo, GroupNode, LinkNode,
+ Node, TextNode,
+ },
+};
+
+/// Type alias for the pixel coordinate unit.
+pub type PixelCoordinate = i64;
+/// Type alias for the pixel dimension unit.
+pub type PixelDimension = u64;
+
+#[cfg(test)]
+mod test {
+ use hex_color::HexColor;
+
+ #[allow(clippy::too_many_lines, reason = "this is a test... :/")]
+ #[allow(clippy::print_stdout)]
+ #[test]
+ fn test() {
+ use std::path::PathBuf;
+
+ use url::Url;
+
+ use super::{
+ canvas::Canvas,
+ color::{Color, PresetColor},
+ edge::{Edge, End, Side},
+ node::{Background, BackgroundStyle, FileNode, GroupNode, LinkNode, Node, TextNode},
+ };
+
+ // Color
+ let color1 = Color::Preset(PresetColor::Red);
+ let color2 = Color::Color(HexColor::parse("#ff0000").unwrap());
+
+ // Text Node
+ let node1: Node = TextNode::new(
+ "id".parse().unwrap(),
+ 0,
+ 0,
+ 100,
+ 100,
+ Some(Color::Preset(PresetColor::Red)),
+ "This is a test".to_string(),
+ )
+ .into();
+
+ // File Node
+ let node2: Node = FileNode::new(
+ "id2".parse().unwrap(),
+ 0,
+ 0,
+ 100,
+ 100,
+ Some(Color::Preset(PresetColor::Red)),
+ PathBuf::from("dir/to/path/file.png"),
+ None,
+ )
+ .into();
+ let node3: Node = FileNode::new(
+ "id3".parse().unwrap(),
+ 0,
+ 0,
+ 100,
+ 100,
+ Some(color1),
+ PathBuf::from("dir/to/path/file.png"),
+ Some("#here".to_string()),
+ )
+ .into();
+
+ // Link Node
+ let node4: Node = LinkNode::new(
+ "id4".parse().unwrap(),
+ 0,
+ 0,
+ 100,
+ 100,
+ Some(Color::Preset(PresetColor::Red)),
+ Url::parse("https://julienduroure.com").unwrap(),
+ )
+ .into();
+
+ // Group Node
+ let node5: Node = GroupNode::new(
+ "id5".parse().unwrap(),
+ 0,
+ 0,
+ 100,
+ 100,
+ Some(color2),
+ Some("Label".to_string()),
+ None,
+ )
+ .into();
+ let node6: Node =
+ GroupNode::new("id6".parse().unwrap(), 0, 0, 100, 100, None, None, None).into();
+ let node7: Node = GroupNode::new(
+ "id7".parse().unwrap(),
+ 0,
+ 0,
+ 100,
+ 100,
+ None,
+ None,
+ Some(Background::new(PathBuf::from("path/to/image.png"), None)),
+ )
+ .into();
+ let node8: Node = GroupNode::new(
+ "id8".parse().unwrap(),
+ 0,
+ 0,
+ 100,
+ 100,
+ None,
+ None,
+ Some(Background::new(
+ PathBuf::from("path/to/image.png"),
+ Some(BackgroundStyle::Cover),
+ )),
+ )
+ .into();
+
+ // Edge
+
+ let edge1 = Edge::new(
+ "edge1".parse().unwrap(),
+ "id".parse().unwrap(),
+ None,
+ None,
+ "id2".parse().unwrap(),
+ Some(Side::Left),
+ Some(End::Arrow),
+ None,
+ None,
+ );
+ let edge2 = Edge::new(
+ "edge2".parse().unwrap(),
+ "id3".parse().unwrap(),
+ None,
+ None,
+ "id4".parse().unwrap(),
+ Some(Side::Left),
+ Some(End::Arrow),
+ Some(Color::Preset(PresetColor::Cyan)),
+ Some("edge label".to_string()),
+ );
+
+ // JSON Canvas
+ let mut canvas = Canvas::default();
+ canvas.add_node(node1).unwrap();
+ canvas.add_node(node2).unwrap();
+ canvas.add_node(node3).unwrap();
+ canvas.add_node(node4).unwrap();
+ canvas.add_node(node5).unwrap();
+ canvas.add_node(node6).unwrap();
+ canvas.add_node(node7).unwrap();
+ canvas.add_node(node8).unwrap();
+
+ canvas.add_edge(edge1).unwrap();
+ canvas.add_edge(edge2).unwrap();
+
+ let serialized_canvas = canvas.to_string();
+
+ println!("serialized canvas = {serialized_canvas}");
+
+ ///////////////////////////// Deserialization /////////////////////////////
+
+ // let deserialized_node1: Node = serde_json::from_str(&serialized_node1).unwrap();
+ // println!("deserialized node 1= {:?}", deserialized_node1);
+
+ // let deseralied_edge1: Edge = serde_json::from_str(&serialized_edge1).unwrap();
+ // println!("deserialized edge 1= {:?}", deseralied_edge1);
+
+ let _jsoncanvas_deserialized: Canvas = serialized_canvas.parse().unwrap();
+ }
+}
diff --git a/sable-canvas/src/node/file.rs b/sable-canvas/src/node/file.rs
new file mode 100644
index 0000000..01079a0
+use std::path::PathBuf;
+
+use crate::{
+ NodeId, PixelCoordinate, PixelDimension,
+ color::Color,
+ node::{GenericNode, GenericNodeInfo},
+};
+
+/// A file node.
+#[derive(
+ Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize,
+)]
+pub struct FileNode {
+ #[serde(flatten)]
+ generic: GenericNode,
+ file: PathBuf,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ subpath: Option<String>,
+}
+
+impl FileNode {
+ /// Creates a new [`FileNode`].
+ #[allow(clippy::too_many_arguments)]
+ #[must_use]
+ pub const fn new(
+ id: NodeId,
+ x: PixelCoordinate,
+ y: PixelCoordinate,
+ width: PixelDimension,
+ height: PixelDimension,
+ color: Option<Color>,
+ file: PathBuf,
+ subpath: Option<String>,
+ ) -> Self {
+ Self {
+ generic: GenericNode::new(id, x, y, width, height, color),
+ file,
+ subpath,
+ }
+ }
+
+ /// Returns a reference to the file of this [`FileNode`].
+ #[must_use]
+ pub const fn file(&self) -> &PathBuf {
+ &self.file
+ }
+
+ /// Returns the subpath of this [`FileNode`].
+ #[must_use]
+ pub const fn subpath(&self) -> Option<&String> {
+ self.subpath.as_ref()
+ }
+}
+
+impl GenericNodeInfo for FileNode {
+ fn id(&self) -> &NodeId {
+ self.generic.id()
+ }
+
+ fn x(&self) -> PixelCoordinate {
+ self.generic.x()
+ }
+
+ fn y(&self) -> PixelCoordinate {
+ self.generic.y()
+ }
+
+ fn width(&self) -> PixelDimension {
+ self.generic.width()
+ }
+
+ fn height(&self) -> PixelDimension {
+ self.generic.height()
+ }
+
+ fn color(&self) -> &Option<Color> {
+ self.generic.color()
+ }
+}
diff --git a/sable-canvas/src/node/group.rs b/sable-canvas/src/node/group.rs
new file mode 100644
index 0000000..418b905
+use std::path::PathBuf;
+
+use crate::{
+ NodeId, PixelCoordinate, PixelDimension,
+ color::Color,
+ node::{GenericNode, GenericNodeInfo},
+};
+
+/// A group node.
+#[derive(
+ Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize,
+)]
+pub struct GroupNode {
+ #[serde(flatten)]
+ generic: GenericNode,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ label: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ #[serde(flatten)]
+ background: Option<Background>,
+}
+
+impl GroupNode {
+ /// Creates a new [`GroupNode`].
+ #[allow(clippy::too_many_arguments)]
+ #[must_use]
+ pub const fn new(
+ id: NodeId,
+ x: PixelCoordinate,
+ y: PixelCoordinate,
+ width: PixelDimension,
+ height: PixelDimension,
+ color: Option<Color>,
+ label: Option<String>,
+ background: Option<Background>,
+ ) -> Self {
+ Self {
+ generic: GenericNode::new(id, x, y, width, height, color),
+ label,
+ background,
+ }
+ }
+
+ /// Returns the label of this [`GroupNode`].
+ #[must_use]
+ pub const fn label(&self) -> Option<&String> {
+ self.label.as_ref()
+ }
+
+ /// Returns the background of this [`GroupNode`].
+ #[must_use]
+ pub const fn background(&self) -> Option<&Background> {
+ self.background.as_ref()
+ }
+}
+
+impl GenericNodeInfo for GroupNode {
+ fn id(&self) -> &NodeId {
+ self.generic.id()
+ }
+
+ fn x(&self) -> PixelCoordinate {
+ self.generic.x()
+ }
+
+ fn y(&self) -> PixelCoordinate {
+ self.generic.y()
+ }
+
+ fn width(&self) -> PixelDimension {
+ self.generic.width()
+ }
+
+ fn height(&self) -> PixelDimension {
+ self.generic.height()
+ }
+
+ fn color(&self) -> &Option<Color> {
+ self.generic.color()
+ }
+}
+
+/// The background attributes of a [`GroupNode`].
+#[derive(
+ Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize,
+)]
+#[serde(rename_all = "camelCase")]
+pub struct Background {
+ image: PathBuf,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ background_style: Option<BackgroundStyle>,
+}
+
+impl Background {
+ /// Creates a new [`Background`].
+ #[must_use]
+ pub const fn new(image: PathBuf, background_style: Option<BackgroundStyle>) -> Self {
+ Self {
+ image,
+ background_style,
+ }
+ }
+
+ /// Returns a reference to the image of this [`Background`].
+ #[must_use]
+ pub const fn image(&self) -> &PathBuf {
+ &self.image
+ }
+
+ /// Returns the style of this [`Background`].
+ #[must_use]
+ pub const fn style(&self) -> Option<BackgroundStyle> {
+ self.background_style
+ }
+}
+
+/// The rendering style of the background image.
+#[derive(
+ Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize,
+)]
+#[serde(rename_all = "camelCase")]
+pub enum BackgroundStyle {
+ /// Fills the entire width and height of the node.
+ Cover,
+ /// Maintains the aspect ratio of the background image.
+ Ratio,
+ /// Repeats the image as a pattern in both x/y directions.
+ Repeat,
+}
diff --git a/sable-canvas/src/node/link.rs b/sable-canvas/src/node/link.rs
new file mode 100644
index 0000000..502962c
+use url::Url;
+
+use crate::{
+ NodeId, PixelCoordinate, PixelDimension,
+ color::Color,
+ node::{GenericNode, GenericNodeInfo},
+};
+
+/// A link node.
+#[derive(
+ Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize,
+)]
+pub struct LinkNode {
+ #[serde(flatten)]
+ generic: GenericNode,
+ url: Url,
+}
+
+impl LinkNode {
+ /// Creates a new [`LinkNode`].
+ #[must_use]
+ pub const fn new(
+ id: NodeId,
+ x: PixelCoordinate,
+ y: PixelCoordinate,
+ width: PixelDimension,
+ height: PixelDimension,
+ color: Option<Color>,
+ url: Url,
+ ) -> Self {
+ Self {
+ generic: GenericNode::new(id, x, y, width, height, color),
+ url,
+ }
+ }
+
+ /// Returns a reference to the url of this [`LinkNode`].
+ #[must_use]
+ pub const fn url(&self) -> &Url {
+ &self.url
+ }
+}
+
+impl GenericNodeInfo for LinkNode {
+ fn id(&self) -> &NodeId {
+ self.generic.id()
+ }
+
+ fn x(&self) -> PixelCoordinate {
+ self.generic.x()
+ }
+
+ fn y(&self) -> PixelCoordinate {
+ self.generic.y()
+ }
+
+ fn width(&self) -> PixelDimension {
+ self.generic.width()
+ }
+
+ fn height(&self) -> PixelDimension {
+ self.generic.height()
+ }
+
+ fn color(&self) -> &Option<Color> {
+ self.generic.color()
+ }
+}
diff --git a/sable-canvas/src/node/mod.rs b/sable-canvas/src/node/mod.rs
new file mode 100644
index 0000000..59e19b5
+mod file;
+mod group;
+mod link;
+mod text;
+
+use crate::{NodeId, PixelCoordinate, PixelDimension, color::Color};
+
+pub use self::{
+ file::FileNode,
+ group::{Background, BackgroundStyle, GroupNode},
+ link::LinkNode,
+ text::TextNode,
+};
+
+/// The shared attributes all nodes have.
+#[derive(
+ Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize,
+)]
+pub struct GenericNode {
+ /// The unique ID of the node.
+ pub id: NodeId,
+ x: PixelCoordinate,
+ y: PixelCoordinate,
+ width: PixelDimension,
+ height: PixelDimension,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ color: Option<Color>,
+}
+
+impl GenericNode {
+ /// Creates a new [`GenericNode`].
+ #[must_use]
+ pub const fn new(
+ id: NodeId,
+ x: PixelCoordinate,
+ y: PixelCoordinate,
+ width: PixelDimension,
+ height: PixelDimension,
+ color: Option<Color>,
+ ) -> Self {
+ Self {
+ id,
+ x,
+ y,
+ width,
+ height,
+ color,
+ }
+ }
+}
+
+/// Trait to access the shared attributes of all nodes.
+pub trait GenericNodeInfo {
+ /// Get the unique ID of the node.
+ fn id(&self) -> &NodeId;
+ /// Get the `x` position of the node in pixels.
+ fn x(&self) -> PixelCoordinate;
+ /// Get the `y` position of the node in pixels.
+ fn y(&self) -> PixelCoordinate;
+ /// Get the `width` position of the node in pixels.
+ fn width(&self) -> PixelDimension;
+ /// Get the `height` position of the node in pixels.
+ fn height(&self) -> PixelDimension;
+ /// Get color of the node.
+ fn color(&self) -> &Option<Color>;
+}
+
+impl GenericNodeInfo for GenericNode {
+ fn id(&self) -> &NodeId {
+ &self.id
+ }
+
+ fn x(&self) -> PixelCoordinate {
+ self.x
+ }
+
+ fn y(&self) -> PixelCoordinate {
+ self.y
+ }
+
+ fn width(&self) -> PixelDimension {
+ self.width
+ }
+
+ fn height(&self) -> PixelDimension {
+ self.height
+ }
+
+ fn color(&self) -> &Option<Color> {
+ &self.color
+ }
+}
+
+/// Wrapper around all the types of nodes of a canvas.
+#[derive(Debug, serde::Serialize, serde::Deserialize)]
+#[serde(tag = "type", rename_all = "camelCase")]
+pub enum Node {
+ /// A text node.
+ ///
+ /// See [`TextNode`].
+ Text(TextNode),
+ /// A file node.
+ ///
+ /// See [`FileNode`].
+ File(FileNode),
+ /// A link node.
+ ///
+ /// See [`LinkNode`].
+ Link(LinkNode),
+ /// A group node.
+ ///
+ /// See [`GroupNode`].
+ Group(GroupNode),
+}
+
+impl From<GroupNode> for Node {
+ fn from(node: GroupNode) -> Self {
+ Self::Group(node)
+ }
+}
+
+impl From<TextNode> for Node {
+ fn from(node: TextNode) -> Self {
+ Self::Text(node)
+ }
+}
+
+impl From<FileNode> for Node {
+ fn from(node: FileNode) -> Self {
+ Self::File(node)
+ }
+}
+
+impl From<LinkNode> for Node {
+ fn from(node: LinkNode) -> Self {
+ Self::Link(node)
+ }
+}
+
+impl GenericNodeInfo for Node {
+ fn id(&self) -> &NodeId {
+ match &self {
+ Self::Text(node) => node.id(),
+ Self::File(node) => node.id(),
+ Self::Link(node) => node.id(),
+ Self::Group(node) => node.id(),
+ }
+ }
+
+ fn x(&self) -> PixelCoordinate {
+ match &self {
+ Self::Text(node) => node.x(),
+ Self::File(node) => node.x(),
+ Self::Link(node) => node.x(),
+ Self::Group(node) => node.x(),
+ }
+ }
+
+ fn y(&self) -> PixelCoordinate {
+ match &self {
+ Self::Text(node) => node.y(),
+ Self::File(node) => node.y(),
+ Self::Link(node) => node.y(),
+ Self::Group(node) => node.y(),
+ }
+ }
+
+ fn width(&self) -> PixelDimension {
+ match &self {
+ Self::Text(node) => node.width(),
+ Self::File(node) => node.width(),
+ Self::Link(node) => node.width(),
+ Self::Group(node) => node.width(),
+ }
+ }
+
+ fn height(&self) -> PixelDimension {
+ match &self {
+ Self::Text(node) => node.height(),
+ Self::File(node) => node.height(),
+ Self::Link(node) => node.height(),
+ Self::Group(node) => node.height(),
+ }
+ }
+
+ fn color(&self) -> &Option<Color> {
+ match &self {
+ Self::Text(node) => node.color(),
+ Self::File(node) => node.color(),
+ Self::Link(node) => node.color(),
+ Self::Group(node) => node.color(),
+ }
+ }
+}
diff --git a/sable-canvas/src/node/text.rs b/sable-canvas/src/node/text.rs
new file mode 100644
index 0000000..be539b5
+use crate::{
+ NodeId, PixelCoordinate, PixelDimension,
+ color::Color,
+ node::{GenericNode, GenericNodeInfo},
+};
+
+/// A text node.
+#[derive(
+ Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize,
+)]
+pub struct TextNode {
+ #[serde(flatten)]
+ generic: GenericNode,
+ text: String,
+}
+
+impl TextNode {
+ /// Creates a new [`TextNode`].
+ #[must_use]
+ pub const fn new(
+ id: NodeId,
+ x: PixelCoordinate,
+ y: PixelCoordinate,
+ width: PixelDimension,
+ height: PixelDimension,
+ color: Option<Color>,
+ text: String,
+ ) -> Self {
+ Self {
+ generic: GenericNode::new(id, x, y, width, height, color),
+ text,
+ }
+ }
+
+ /// Returns a reference to the text of this [`TextNode`].
+ #[must_use]
+ pub const fn text(&self) -> &str {
+ self.text.as_str()
+ }
+}
+
+impl GenericNodeInfo for TextNode {
+ fn id(&self) -> &NodeId {
+ self.generic.id()
+ }
+
+ fn x(&self) -> PixelCoordinate {
+ self.generic.x()
+ }
+
+ fn y(&self) -> PixelCoordinate {
+ self.generic.y()
+ }
+
+ fn width(&self) -> PixelDimension {
+ self.generic.width()
+ }
+
+ fn height(&self) -> PixelDimension {
+ self.generic.height()
+ }
+
+ fn color(&self) -> &Option<Color> {
+ self.generic.color()
+ }
+}
diff --git a/sable-core/Cargo.toml b/sable-core/Cargo.toml
new file mode 100644
index 0000000..a8432be
+[package]
+name = "sable-core"
+version = "0.1.0"
+edition = "2024"
+
+[dependencies]
+miette.workspace = true
+serde.workspace = true
+serde_json.workspace = true
+serde_toml.workspace = true
+thiserror.workspace = true
diff --git a/sable-core/src/config.rs b/sable-core/src/config.rs
new file mode 100644
index 0000000..8cc4fe2
+use std::{collections::HashMap, path::PathBuf, sync::LazyLock};
+
+static CWD: LazyLock<PathBuf> = LazyLock::new(|| {
+ std::env::current_dir()
+ .expect("failed to get current working directory")
+ .canonicalize()
+ .expect("failed to canonicalize current working directory")
+});
+
+#[derive(Debug, miette::Diagnostic, thiserror::Error)]
+pub enum ConfigError {
+ #[error("failed to read config file")]
+ ReadFile(#[source] std::io::Error),
+ #[error("failed to deserialize config file")]
+ Deserialize(#[source] serde_toml::de::Error),
+ #[error("failed to create missing directory")]
+ CreateDir(#[source] std::io::Error),
+ #[error("failed to canonicalize config's file paths ({1})")]
+ Canonicalize(#[source] std::io::Error, PathBuf),
+}
+
+#[derive(Debug, Clone, serde::Deserialize)]
+#[serde(default)]
+pub struct Config {
+ pub build: PathBuf,
+ pub r#static: PathBuf,
+ pub templates: PathBuf,
+ pub vault: PathBuf,
+
+ pub port: u16,
+
+ pub default_template: Option<String>,
+
+ pub assets: Vec<AssetConfig>,
+
+ pub modules: ModulesConfig,
+
+ pub pages: Vec<PageConfig>,
+
+ pub data: Option<serde_json::Value>,
+}
+
+impl Config {
+ pub fn load() -> Result<Self, ConfigError> {
+ let path = CWD.join("sable.toml");
+
+ let config = if path.exists() {
+ let config = std::fs::read_to_string(path).map_err(ConfigError::ReadFile)?;
+
+ let mut config: Self =
+ serde_toml::from_str(&config).map_err(ConfigError::Deserialize)?;
+
+ config.canonicalize()?;
+
+ config
+ } else {
+ Self::default()
+ };
+
+ Ok(config)
+ }
+
+ fn canonicalize(&mut self) -> Result<(), ConfigError> {
+ std::fs::create_dir_all(&self.build).map_err(ConfigError::CreateDir)?;
+
+ self.build = self
+ .build
+ .canonicalize()
+ .map_err(|err| ConfigError::Canonicalize(err, self.build.clone()))?;
+ self.r#static = self
+ .r#static
+ .canonicalize()
+ .map_err(|err| ConfigError::Canonicalize(err, self.r#static.clone()))?;
+ self.templates = self
+ .templates
+ .canonicalize()
+ .map_err(|err| ConfigError::Canonicalize(err, self.templates.clone()))?;
+ self.vault = self
+ .vault
+ .canonicalize()
+ .map_err(|err| ConfigError::Canonicalize(err, self.vault.clone()))?;
+
+ Ok(())
+ }
+}
+
+impl Default for Config {
+ fn default() -> Self {
+ Self {
+ build: CWD.join("dist"),
+ r#static: CWD.join("static"),
+ templates: CWD.join("templates"),
+ vault: CWD.join("content"),
+
+ port: 3000,
+
+ default_template: None,
+
+ assets: Vec::new(),
+
+ modules: ModulesConfig::default(),
+
+ pages: Vec::new(),
+
+ data: None,
+ }
+ }
+}
+
+#[derive(Debug, Clone, serde::Deserialize)]
+pub struct AssetConfig {
+ pub output: PathBuf,
+ pub command: String,
+ #[serde(default)]
+ pub env: Option<HashMap<String, String>>,
+}
+
+#[derive(Debug, Clone, Default, serde::Deserialize)]
+#[serde(default)]
+pub struct ModulesConfig {
+ pub rss: RssConfig,
+}
+
+#[derive(Debug, Clone, Default, serde::Deserialize)]
+#[serde(default)]
+pub struct RssConfig {
+ pub enabled: bool,
+ pub template: String,
+ pub path: String,
+}
+
+#[derive(Debug, Clone, Default, serde::Deserialize)]
+#[serde(default)]
+pub struct PageConfig {
+ pub template: String,
+ pub path: String,
+ pub metadata: Option<serde_json::Value>,
+}
diff --git a/sable-core/src/lib.rs b/sable-core/src/lib.rs
new file mode 100644
index 0000000..422b4f1
+#![deny(rust_2018_idioms, unsafe_code)]
+#![warn(
+ absolute_paths_not_starting_with_crate,
+ ambiguous_associated_items,
+ anonymous_parameters,
+ arithmetic_overflow,
+ array_into_iter,
+ asm_sub_register,
+ bad_asm_style,
+ bindings_with_variant_name,
+ break_with_label_and_loop,
+ clashing_extern_declarations,
+ coherence_leak_check,
+ conflicting_repr_hints,
+ confusable_idents,
+ const_evaluatable_unchecked,
+ const_item_mutation,
+ dangling_pointers_from_temporaries,
+ dead_code,
+ deprecated_in_future,
+ deprecated_where_clause_location,
+ deprecated,
+ deref_into_dyn_supertrait,
+ deref_nullptr,
+ drop_bounds,
+ duplicate_macro_attributes,
+ dyn_drop,
+ ellipsis_inclusive_range_patterns,
+ enum_intrinsics_non_enums,
+ explicit_outlives_requirements,
+ exported_private_dependencies,
+ forbidden_lint_groups,
+ function_item_references,
+ future_incompatible,
+ ill_formed_attribute_input,
+ improper_ctypes_definitions,
+ improper_ctypes,
+ incomplete_features,
+ incomplete_include,
+ ineffective_unstable_trait_impl,
+ inline_no_sanitize,
+ invalid_atomic_ordering,
+ invalid_doc_attributes,
+ invalid_type_param_default,
+ invalid_value,
+ irrefutable_let_patterns,
+ keyword_idents,
+ large_assignments,
+ late_bound_lifetime_arguments,
+ legacy_derive_helpers,
+ macro_expanded_macro_exports_accessed_by_absolute_paths,
+ meta_variable_misuse,
+ missing_abi,
+ missing_copy_implementations,
+ missing_debug_implementations,
+ missing_docs,
+ mixed_script_confusables,
+ mutable_transmutes,
+ named_arguments_used_positionally,
+ named_asm_labels,
+ no_mangle_const_items,
+ no_mangle_generic_items,
+ non_ascii_idents,
+ non_camel_case_types,
+ non_fmt_panics,
+ non_shorthand_field_patterns,
+ non_snake_case,
+ non_upper_case_globals,
+ nonstandard_style,
+ noop_method_call,
+ overflowing_literals,
+ overlapping_range_endpoints,
+ path_statements,
+ patterns_in_fns_without_body,
+ proc_macro_derive_resolution_fallback,
+ pub_use_of_private_extern_crate,
+ redundant_semicolons,
+ repr_transparent_external_private_fields,
+ rust_2021_incompatible_closure_captures,
+ rust_2021_incompatible_or_patterns,
+ rust_2021_prefixes_incompatible_syntax,
+ rust_2021_prelude_collisions,
+ semicolon_in_expressions_from_macros,
+ soft_unstable,
+ stable_features,
+ text_direction_codepoint_in_comment,
+ text_direction_codepoint_in_literal,
+ trivial_bounds,
+ trivial_casts,
+ trivial_numeric_casts,
+ type_alias_bounds,
+ tyvar_behind_raw_pointer,
+ uncommon_codepoints,
+ unconditional_panic,
+ unconditional_recursion,
+ unexpected_cfgs,
+ uninhabited_static,
+ unknown_crate_types,
+ unnameable_test_items,
+ unreachable_code,
+ unreachable_patterns,
+ unreachable_pub,
+ unsafe_op_in_unsafe_fn,
+ unstable_features,
+ unstable_name_collisions,
+ unused_allocation,
+ unused_assignments,
+ unused_attributes,
+ unused_braces,
+ unused_comparisons,
+ unused_crate_dependencies,
+ unused_doc_comments,
+ unused_extern_crates,
+ unused_features,
+ unused_import_braces,
+ unused_imports,
+ unused_labels,
+ unused_lifetimes,
+ unused_macro_rules,
+ unused_macros,
+ unused_must_use,
+ unused_mut,
+ unused_parens,
+ unused_qualifications,
+ unused_unsafe,
+ unused_variables,
+ useless_deprecated,
+ while_true
+)]
+#![warn(
+ clippy::all,
+ clippy::await_holding_lock,
+ clippy::char_lit_as_u8,
+ clippy::checked_conversions,
+ clippy::cognitive_complexity,
+ clippy::dbg_macro,
+ clippy::debug_assert_with_mut_call,
+ clippy::disallowed_script_idents,
+ clippy::doc_link_with_quotes,
+ clippy::doc_markdown,
+ clippy::empty_enum,
+ clippy::empty_line_after_outer_attr,
+ clippy::empty_structs_with_brackets,
+ clippy::enum_glob_use,
+ clippy::equatable_if_let,
+ clippy::exit,
+ clippy::expl_impl_clone_on_copy,
+ clippy::explicit_deref_methods,
+ clippy::explicit_into_iter_loop,
+ clippy::fallible_impl_from,
+ clippy::filter_map_next,
+ clippy::flat_map_option,
+ clippy::float_cmp_const,
+ clippy::float_cmp,
+ clippy::float_equality_without_abs,
+ clippy::fn_params_excessive_bools,
+ clippy::fn_to_numeric_cast_any,
+ clippy::from_iter_instead_of_collect,
+ clippy::if_let_mutex,
+ clippy::implicit_clone,
+ clippy::imprecise_flops,
+ clippy::index_refutable_slice,
+ clippy::inefficient_to_string,
+ clippy::invalid_upcast_comparisons,
+ clippy::iter_not_returning_iterator,
+ clippy::large_digit_groups,
+ clippy::large_stack_arrays,
+ clippy::large_types_passed_by_value,
+ clippy::let_unit_value,
+ clippy::linkedlist,
+ clippy::lossy_float_literal,
+ clippy::macro_use_imports,
+ clippy::manual_ok_or,
+ clippy::map_err_ignore,
+ clippy::map_flatten,
+ clippy::map_unwrap_or,
+ clippy::match_same_arms,
+ clippy::match_wild_err_arm,
+ clippy::match_wildcard_for_single_variants,
+ clippy::mem_forget,
+ clippy::missing_const_for_fn,
+ clippy::missing_enforced_import_renames,
+ clippy::missing_errors_doc,
+ clippy::missing_panics_doc,
+ clippy::mut_mut,
+ clippy::mutex_integer,
+ clippy::needless_borrow,
+ clippy::needless_continue,
+ clippy::needless_for_each,
+ clippy::needless_pass_by_value,
+ clippy::negative_feature_names,
+ clippy::nonstandard_macro_braces,
+ clippy::nursery,
+ clippy::option_if_let_else,
+ clippy::option_option,
+ clippy::path_buf_push_overwrite,
+ clippy::pedantic,
+ clippy::print_stderr,
+ clippy::print_stdout,
+ clippy::ptr_as_ptr,
+ clippy::rc_mutex,
+ clippy::ref_option_ref,
+ clippy::rest_pat_in_fully_bound_structs,
+ clippy::same_functions_in_if_condition,
+ clippy::semicolon_if_nothing_returned,
+ clippy::shadow_unrelated,
+ clippy::similar_names,
+ clippy::single_match_else,
+ clippy::string_add_assign,
+ clippy::string_add,
+ clippy::string_lit_as_bytes,
+ clippy::suspicious_operation_groupings,
+ clippy::todo,
+ clippy::trailing_empty_array,
+ clippy::trait_duplication_in_bounds,
+ clippy::trivially_copy_pass_by_ref,
+ clippy::unimplemented,
+ clippy::unnecessary_wraps,
+ clippy::unnested_or_patterns,
+ clippy::unseparated_literal_suffix,
+ clippy::unused_self,
+ clippy::use_debug,
+ clippy::use_self,
+ clippy::used_underscore_binding,
+ clippy::useless_let_if_seq,
+ clippy::useless_transmute,
+ clippy::verbose_file_reads,
+ clippy::wildcard_dependencies,
+ clippy::wildcard_imports,
+ clippy::zero_sized_map_values
+)]
+
+//! The core library of `sable`.
+
+pub mod config;
+
+/// A macro to create [`MetaInfo`].
+#[macro_export]
+macro_rules! load_info {
+ () => {{
+ ::sable_core::MetaInfo {
+ package_name: env!("CARGO_PKG_NAME"),
+ package_version: env!("CARGO_PKG_VERSION"),
+
+ git_dirty: match env!("VERGEN_GIT_DIRTY").as_bytes().first().copied() {
+ Some(b't') => true,
+ Some(_) | None => false,
+ },
+ git_hash: env!("VERGEN_GIT_SHA"),
+ }
+ }};
+}
+
+/// Meta build-info of `sable`.
+#[derive(Debug, Clone, Copy, serde::Serialize)]
+pub struct MetaInfo {
+ /// The package name.
+ ///
+ /// Normally `sable`.
+ pub package_name: &'static str,
+ /// The version of `sable` itself.
+ pub package_version: &'static str,
+
+ /// Is `true` if the git repo had modification when `sable` was built.
+ pub git_dirty: bool,
+ /// The full git commit hash of the source that was built.
+ pub git_hash: &'static str,
+}
diff --git a/sable-frontmatter/Cargo.toml b/sable-frontmatter/Cargo.toml
new file mode 100644
index 0000000..36367f4
+[package]
+name = "sable-frontmatter"
+version = "0.1.0"
+edition = "2024"
+
+[features]
+default = ["json", "toml", "yaml"]
+
+json = []
+toml = ["serde_toml"]
+yaml = ["serde_yaml"]
+
+[dependencies]
+memchr.workspace = true
+miette.workspace = true
+thiserror.workspace = true
+
+serde_json = { workspace = true, optional = false }
+serde_toml = { workspace = true, optional = true }
+serde_yaml = { workspace = true, optional = true }
diff --git a/sable-frontmatter/README.md b/sable-frontmatter/README.md
new file mode 100644
index 0000000..e6907d9
+# sable-frontmatter
+
+a multi-format frontmatter parser extracted from sable.
diff --git a/sable-frontmatter/src/json.rs b/sable-frontmatter/src/json.rs
new file mode 100644
index 0000000..35d1560
+//! Parse JSON frontmatter
+
+use miette::{SourceOffset, SourceSpan};
+
+use crate::Metadata;
+
+/// JSON error.
+#[derive(Debug, miette::Diagnostic, thiserror::Error)]
+pub enum Error {
+ /// The JSON wasn't 'valid'.
+ ///
+ /// IE: the 'parser' couldn't count the braces properly.
+ #[error("frontmatter is not valid json")]
+ Invalid,
+ /// Too many braces were counted.
+ ///
+ /// This was 'thrown' to prevent an 'endless' loop of parser.
+ #[error("json frontmatter exceeded parse depth of 128 braces (come on :/)")]
+ DepthExceeded,
+ /// JSON parse error
+ #[error(transparent)]
+ Parse(ParseError),
+}
+
+/// JSON parse error
+#[derive(Debug, miette::Diagnostic, thiserror::Error)]
+#[error("failed to deserialize toml frontmatter")]
+pub struct ParseError {
+ /// The 'source' of the frontmatter.
+ #[source_code]
+ src: String,
+ /// The location where [`serde_json`] failed to parse.
+ #[label("{err}")]
+ location: SourceSpan,
+
+ /// The error emitted.
+ #[source]
+ err: serde_json::Error,
+}
+
+/// Parse JSON frontmatter by counting the braces.
+///
+/// # Errors
+///
+/// This is will if the basic parser cannot count braces properly,
+/// the brace count is too high (to prevent endless parsing),
+/// or the contents failed to parse.
+pub fn parse(data: &str) -> Result<(Option<Metadata>, &str), Error> {
+ const MAX_DEPTH: usize = 128;
+
+ let data = data.trim_start();
+
+ if !data.starts_with('{') {
+ return Err(Error::Invalid);
+ }
+
+ let mut depth = 0;
+
+ let mut braces = 0;
+
+ let mut is_in_string = false;
+ let mut escape_next = false;
+
+ let mut split_point = 0;
+
+ for (i, ch) in data.char_indices() {
+ if escape_next {
+ escape_next = false;
+ continue;
+ }
+
+ match ch {
+ '"' if !is_in_string => {
+ is_in_string = true;
+ }
+ '"' if is_in_string => {
+ is_in_string = false;
+ }
+ '\\' if is_in_string => {
+ escape_next = true;
+ }
+ '{' if !is_in_string => {
+ depth += 1;
+
+ braces += 1;
+
+ if depth > MAX_DEPTH {
+ return Err(Error::DepthExceeded);
+ }
+ }
+ '}' if !is_in_string => {
+ braces -= 1;
+
+ if depth > 0 {
+ depth = depth.saturating_sub(1);
+ }
+
+ if braces == 0 {
+ split_point = i;
+
+ break;
+ }
+ }
+ _ => {}
+ }
+ }
+
+ if braces != 0 {
+ return Err(Error::Invalid);
+ }
+
+ let (frontmatter, body) = data.split_at(split_point);
+
+ let parsed = serde_json::from_str(frontmatter).map_err(|err| {
+ Error::Parse(ParseError {
+ src: frontmatter.to_string(),
+ location: SourceSpan::new(
+ SourceOffset::from_location(frontmatter, err.line(), err.column()),
+ 1,
+ ),
+ err,
+ })
+ })?;
+
+ Ok((Some(parsed), body))
+}
diff --git a/sable-frontmatter/src/lib.rs b/sable-frontmatter/src/lib.rs
new file mode 100644
index 0000000..4cc25cf
+#![deny(rust_2018_idioms, unsafe_code)]
+#![warn(
+ absolute_paths_not_starting_with_crate,
+ ambiguous_associated_items,
+ anonymous_parameters,
+ arithmetic_overflow,
+ array_into_iter,
+ asm_sub_register,
+ bad_asm_style,
+ bindings_with_variant_name,
+ break_with_label_and_loop,
+ clashing_extern_declarations,
+ coherence_leak_check,
+ conflicting_repr_hints,
+ confusable_idents,
+ const_evaluatable_unchecked,
+ const_item_mutation,
+ dangling_pointers_from_temporaries,
+ dead_code,
+ deprecated_in_future,
+ deprecated_where_clause_location,
+ deprecated,
+ deref_into_dyn_supertrait,
+ deref_nullptr,
+ drop_bounds,
+ duplicate_macro_attributes,
+ dyn_drop,
+ ellipsis_inclusive_range_patterns,
+ enum_intrinsics_non_enums,
+ explicit_outlives_requirements,
+ exported_private_dependencies,
+ forbidden_lint_groups,
+ function_item_references,
+ future_incompatible,
+ ill_formed_attribute_input,
+ improper_ctypes_definitions,
+ improper_ctypes,
+ incomplete_features,
+ incomplete_include,
+ ineffective_unstable_trait_impl,
+ inline_no_sanitize,
+ invalid_atomic_ordering,
+ invalid_doc_attributes,
+ invalid_type_param_default,
+ invalid_value,
+ irrefutable_let_patterns,
+ keyword_idents,
+ large_assignments,
+ late_bound_lifetime_arguments,
+ legacy_derive_helpers,
+ macro_expanded_macro_exports_accessed_by_absolute_paths,
+ meta_variable_misuse,
+ missing_abi,
+ missing_copy_implementations,
+ missing_debug_implementations,
+ missing_docs,
+ mixed_script_confusables,
+ mutable_transmutes,
+ named_arguments_used_positionally,
+ named_asm_labels,
+ no_mangle_const_items,
+ no_mangle_generic_items,
+ non_ascii_idents,
+ non_camel_case_types,
+ non_fmt_panics,
+ non_shorthand_field_patterns,
+ non_snake_case,
+ non_upper_case_globals,
+ nonstandard_style,
+ noop_method_call,
+ overflowing_literals,
+ overlapping_range_endpoints,
+ path_statements,
+ patterns_in_fns_without_body,
+ proc_macro_derive_resolution_fallback,
+ pub_use_of_private_extern_crate,
+ redundant_semicolons,
+ repr_transparent_external_private_fields,
+ rust_2021_incompatible_closure_captures,
+ rust_2021_incompatible_or_patterns,
+ rust_2021_prefixes_incompatible_syntax,
+ rust_2021_prelude_collisions,
+ semicolon_in_expressions_from_macros,
+ soft_unstable,
+ stable_features,
+ text_direction_codepoint_in_comment,
+ text_direction_codepoint_in_literal,
+ trivial_bounds,
+ trivial_casts,
+ trivial_numeric_casts,
+ type_alias_bounds,
+ tyvar_behind_raw_pointer,
+ uncommon_codepoints,
+ unconditional_panic,
+ unconditional_recursion,
+ unexpected_cfgs,
+ uninhabited_static,
+ unknown_crate_types,
+ unnameable_test_items,
+ unreachable_code,
+ unreachable_patterns,
+ unreachable_pub,
+ unsafe_op_in_unsafe_fn,
+ unstable_features,
+ unstable_name_collisions,
+ unused_allocation,
+ unused_assignments,
+ unused_attributes,
+ unused_braces,
+ unused_comparisons,
+ unused_crate_dependencies,
+ unused_doc_comments,
+ unused_extern_crates,
+ unused_features,
+ unused_import_braces,
+ unused_imports,
+ unused_labels,
+ unused_lifetimes,
+ unused_macro_rules,
+ unused_macros,
+ unused_must_use,
+ unused_mut,
+ unused_parens,
+ unused_qualifications,
+ unused_unsafe,
+ unused_variables,
+ useless_deprecated,
+ while_true
+)]
+#![warn(
+ clippy::all,
+ clippy::await_holding_lock,
+ clippy::char_lit_as_u8,
+ clippy::checked_conversions,
+ clippy::cognitive_complexity,
+ clippy::dbg_macro,
+ clippy::debug_assert_with_mut_call,
+ clippy::disallowed_script_idents,
+ clippy::doc_link_with_quotes,
+ clippy::doc_markdown,
+ clippy::empty_enum,
+ clippy::empty_line_after_outer_attr,
+ clippy::empty_structs_with_brackets,
+ clippy::enum_glob_use,
+ clippy::equatable_if_let,
+ clippy::exit,
+ clippy::expl_impl_clone_on_copy,
+ clippy::explicit_deref_methods,
+ clippy::explicit_into_iter_loop,
+ clippy::fallible_impl_from,
+ clippy::filter_map_next,
+ clippy::flat_map_option,
+ clippy::float_cmp_const,
+ clippy::float_cmp,
+ clippy::float_equality_without_abs,
+ clippy::fn_params_excessive_bools,
+ clippy::fn_to_numeric_cast_any,
+ clippy::from_iter_instead_of_collect,
+ clippy::if_let_mutex,
+ clippy::implicit_clone,
+ clippy::imprecise_flops,
+ clippy::index_refutable_slice,
+ clippy::inefficient_to_string,
+ clippy::invalid_upcast_comparisons,
+ clippy::iter_not_returning_iterator,
+ clippy::large_digit_groups,
+ clippy::large_stack_arrays,
+ clippy::large_types_passed_by_value,
+ clippy::let_unit_value,
+ clippy::linkedlist,
+ clippy::lossy_float_literal,
+ clippy::macro_use_imports,
+ clippy::manual_ok_or,
+ clippy::map_err_ignore,
+ clippy::map_flatten,
+ clippy::map_unwrap_or,
+ clippy::match_same_arms,
+ clippy::match_wild_err_arm,
+ clippy::match_wildcard_for_single_variants,
+ clippy::mem_forget,
+ clippy::missing_const_for_fn,
+ clippy::missing_enforced_import_renames,
+ clippy::missing_errors_doc,
+ clippy::missing_panics_doc,
+ clippy::mut_mut,
+ clippy::mutex_integer,
+ clippy::needless_borrow,
+ clippy::needless_continue,
+ clippy::needless_for_each,
+ clippy::needless_pass_by_value,
+ clippy::negative_feature_names,
+ clippy::nonstandard_macro_braces,
+ clippy::nursery,
+ clippy::option_if_let_else,
+ clippy::option_option,
+ clippy::path_buf_push_overwrite,
+ clippy::pedantic,
+ clippy::print_stderr,
+ clippy::print_stdout,
+ clippy::ptr_as_ptr,
+ clippy::rc_mutex,
+ clippy::ref_option_ref,
+ clippy::rest_pat_in_fully_bound_structs,
+ clippy::same_functions_in_if_condition,
+ clippy::semicolon_if_nothing_returned,
+ clippy::shadow_unrelated,
+ clippy::similar_names,
+ clippy::single_match_else,
+ clippy::string_add_assign,
+ clippy::string_add,
+ clippy::string_lit_as_bytes,
+ clippy::string_to_string,
+ clippy::suspicious_operation_groupings,
+ clippy::todo,
+ clippy::trailing_empty_array,
+ clippy::trait_duplication_in_bounds,
+ clippy::trivially_copy_pass_by_ref,
+ clippy::unimplemented,
+ clippy::unnecessary_wraps,
+ clippy::unnested_or_patterns,
+ clippy::unseparated_literal_suffix,
+ clippy::unused_self,
+ clippy::use_debug,
+ clippy::use_self,
+ clippy::used_underscore_binding,
+ clippy::useless_let_if_seq,
+ clippy::useless_transmute,
+ clippy::verbose_file_reads,
+ clippy::wildcard_dependencies,
+ clippy::wildcard_imports,
+ clippy::zero_sized_map_values
+)]
+
+//! A multi-format (JSON, TOML, YAML) fronmatter extractor.
+
+pub mod json;
+pub mod toml;
+pub mod yaml;
+
+/// Frontmatter 'metadata' type.
+pub type Metadata = serde_json::Value;
+
+#[derive(Debug, miette::Diagnostic, thiserror::Error)]
+pub enum MetadataError {
+ #[error(transparent)]
+ #[diagnostic(transparent)]
+ Json(json::Error),
+ #[error(transparent)]
+ #[diagnostic(transparent)]
+ Toml(toml::Error),
+ #[error(transparent)]
+ #[diagnostic(transparent)]
+ Yaml(yaml::Error),
+}
+
+/// Attempt to parse the frontmatter by checking and attempting that formats parser.
+///
+/// # Errors
+///
+/// See [`json::parse`], [`toml::parse`], and [`yaml::parse`] for information on how they could fail.
+pub fn parse(data: &str) -> Result<(Option<Metadata>, &str), MetadataError> {
+ if data.starts_with('{') {
+ json::parse(data).map_err(MetadataError::Json)
+ } else if data.starts_with("+++") {
+ toml::parse(data).map_err(MetadataError::Toml)
+ } else if data.starts_with("---") {
+ yaml::parse(data).map_err(MetadataError::Yaml)
+ } else {
+ Ok((None, data))
+ }
+}
+
+pub(crate) fn parse_simple<'d>(data: &'d str, needle: &str) -> (Option<&'d str>, &'d str) {
+ let mut split = memchr::memmem::find_iter(data.as_bytes(), needle);
+
+ match (split.next(), split.next()) {
+ (Some(start), Some(end)) => {
+ let inner = (start + needle.len())..end;
+
+ let (frontmatter, body) = data.split_at(end + needle.len());
+
+ (Some(&frontmatter[inner]), body)
+ }
+ _ => (None, data),
+ }
+}
diff --git a/sable-frontmatter/src/toml.rs b/sable-frontmatter/src/toml.rs
new file mode 100644
index 0000000..3cd34ad
+//! Parse TOML frontmatter
+
+use miette::{SourceOffset, SourceSpan};
+
+use crate::{Metadata, parse_simple};
+
+/// TOML parse error
+#[derive(Debug, miette::Diagnostic, thiserror::Error)]
+#[error("failed to deserialize toml frontmatter")]
+pub struct Error {
+ /// The 'source' of the frontmatter.
+ #[source_code]
+ src: String,
+ /// The location where [`serde_toml`] failed to parse.
+ #[label("{err}")]
+ location: Option<SourceSpan>,
+
+ /// The error emitted.
+ #[source]
+ err: Box<serde_toml::de::Error>,
+}
+
+/// Locates start and end `+++` and attempts to parse the contents.
+///
+/// # Errors
+///
+/// This will fail if the contents is not valid TOML.
+pub fn parse(data: &str) -> Result<(Option<Metadata>, &str), Error> {
+ let (frontmatter, contents) = match parse_simple(data, "---") {
+ (Some(frontmatter), contents) => (frontmatter, contents),
+ (None, contents) => return Ok((None, contents)),
+ };
+
+ let parsed = match serde_toml::from_str(frontmatter) {
+ Ok(parsed) => parsed,
+ Err(err) => {
+ return Err(Error {
+ src: frontmatter.to_string(),
+ location: err.span().map(|span| {
+ SourceSpan::new(SourceOffset::from(span.start), span.end - span.start)
+ }),
+ err: Box::new(err),
+ });
+ }
+ };
+
+ Ok((Some(parsed), contents))
+}
diff --git a/sable-frontmatter/src/yaml.rs b/sable-frontmatter/src/yaml.rs
new file mode 100644
index 0000000..b2d169b
+//! Parse YAML frontmatter
+
+use miette::SourceOffset;
+
+use crate::{Metadata, parse_simple};
+
+/// YAML parse error
+#[derive(Debug, miette::Diagnostic, thiserror::Error)]
+#[error("failed to deserialize yaml frontmatter")]
+pub struct Error {
+ /// The 'source' of the frontmatter.
+ #[source_code]
+ src: String,
+ /// The location where [`serde_yaml`] failed to parse.
+ #[label("{err}")]
+ location: Option<SourceOffset>,
+
+ /// The error emitted.
+ #[source]
+ err: serde_yaml::Error,
+}
+
+/// Locates start and end `---` and attempts to parse the contents.
+///
+/// # Errors
+///
+/// This will fail if the contents is not valid YAML.
+pub fn parse(data: &str) -> Result<(Option<Metadata>, &str), Error> {
+ let (frontmatter, contents) = match parse_simple(data, "---") {
+ (Some(frontmatter), contents) => (frontmatter, contents),
+ (None, contents) => return Ok((None, contents)),
+ };
+
+ let parsed = match serde_yaml::from_str(frontmatter) {
+ Ok(parsed) => parsed,
+ Err(err) => {
+ return Err(Error {
+ src: frontmatter.to_string(),
+ location: err
+ .location()
+ .map(|loc| SourceOffset::from_location(frontmatter, loc.line(), loc.column())),
+ err,
+ });
+ }
+ };
+
+ Ok((Some(parsed), contents))
+}
diff --git a/sable-markdown/Cargo.toml b/sable-markdown/Cargo.toml
new file mode 100644
index 0000000..276827e
+[package]
+name = "sable-markdown"
+version = "0.1.0"
+edition = "2024"
+
+workspace = ".."
+
+[features]
+default = ["parser", "render", "highlighting-inkjet"]
+
+parser = []
+render = []
+
+highlighting-inkjet = ["dep:inkjet"]
+highlighting-syntect = ["dep:syntect"]
+
+[dependencies]
+entities.workspace = true
+heck.workspace = true
+nom.workspace = true
+pretty.workspace = true
+slug.workspace = true
+unicode_categories.workspace = true
+
+inkjet = { workspace = true, optional = true }
+syntect = { workspace = true, optional = true }
+
+[dev-dependencies]
+rstest.workspace = true
diff --git a/sable-markdown/README.md b/sable-markdown/README.md
new file mode 100644
index 0000000..f8049df
+# sable-markdown
+
+a obsidian markdown parser and renderer.
+
+forked from [johnlepikhin/markdown-ppp](https://github.com/johnlepikhin/markdown-ppp).
diff --git a/sable-markdown/src/ast/mod.rs b/sable-markdown/src/ast/mod.rs
new file mode 100644
index 0000000..ff46627
+//! Fully‑typed Abstract Syntax Tree (AST) for CommonMark + GitHub Flavored Markdown (GFM)
+//! ------------------------------------------------------------------------------------
+//! This module models every construct described in the **CommonMark 1.0 specification**
+//! together with the widely‑used **GFM extensions**: tables, strikethrough, autolinks,
+//! task‑list items and footnotes.
+//!
+//! The design separates **block‑level** and **inline‑level** nodes because parsers and
+//! renderers typically operate on these tiers independently.
+//!
+//! ```text
+//! Document ─┐
+//! └─ Block ─┐
+//! ├─ Inline
+//! └─ ...
+//! ```
+
+// ——————————————————————————————————————————————————————————————————————————
+// Document root
+// ——————————————————————————————————————————————————————————————————————————
+
+/// Root of a Markdown document
+#[derive(Debug, Clone, PartialEq)]
+pub struct Document {
+ /// Top‑level block sequence **in document order**.
+ pub blocks: Vec<Block>,
+}
+
+// ——————————————————————————————————————————————————————————————————————————
+// Block‑level nodes
+// ——————————————————————————————————————————————————————————————————————————
+
+/// Block‑level constructs in the order they appear in the CommonMark spec.
+#[derive(Debug, Clone, PartialEq)]
+pub enum Block {
+ /// Ordinary paragraph
+ Paragraph(Vec<Inline>),
+
+ /// ATX (`# Heading`) or Setext (`===`) heading
+ Heading(Heading),
+
+ /// Thematic break (horizontal rule)
+ ThematicBreak,
+
+ /// Block quote
+ BlockQuote(Vec<Block>),
+
+ /// List (bullet or ordered)
+ List(List),
+
+ /// Fenced or indented code block
+ CodeBlock(CodeBlock),
+
+ /// Raw HTML block
+ HtmlBlock(String),
+
+ /// Link reference definition. Preserved for round‑tripping.
+ Definition(LinkDefinition),
+
+ /// Tables
+ Table(Table),
+
+ /// Footnote definition
+ FootnoteDefinition(FootnoteDefinition),
+
+ /// Callout
+ Callout(Callout),
+
+ /// Empty block. This is used to represent skipped blocks in the AST.
+ Empty,
+}
+
+/// Heading with level 1–6 and inline content.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct Heading {
+ /// Kind of heading (ATX or Setext) together with the level.
+ pub kind: HeadingKind,
+
+ /// Inlines that form the heading text (before trimming).
+ pub content: Vec<Inline>,
+}
+
+/// Heading with level 1–6 and inline content.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum HeadingKind {
+ /// ATX heading (`# Heading`)
+ Atx(u8),
+
+ /// Setext heading (`===` or `---`)
+ Setext(SetextHeading),
+}
+
+/// Setext heading with level and underline type.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum SetextHeading {
+ /// Setext heading with `=` underline
+ Level1,
+
+ /// Setext heading with `-` underline
+ Level2,
+}
+
+// ——————————————————————————————————————————————————————————————————————————
+// Lists
+// ——————————————————————————————————————————————————————————————————————————
+
+/// A list container — bullet or ordered.
+#[derive(Debug, Clone, PartialEq)]
+pub struct List {
+ /// Kind of list together with additional semantic data (start index or
+ /// bullet marker).
+ pub kind: ListKind,
+
+ /// List items in source order.
+ pub items: Vec<ListItem>,
+}
+
+/// Specifies *what kind* of list we have.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum ListKind {
+ /// Ordered list (`1.`, `42.` …) with an *optional* explicit start number.
+ Ordered(ListOrderedKindOptions),
+
+ /// Bullet list (`-`, `*`, or `+`) together with the concrete marker.
+ Bullet(ListBulletKind),
+}
+
+/// Specifies *what kind* of list we have.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct ListOrderedKindOptions {
+ /// Start index (1, 2, …) for ordered lists.
+ pub start: u64,
+}
+
+/// Concrete bullet character used for a bullet list.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum ListBulletKind {
+ /// `-` U+002D
+ Dash,
+
+ /// `*` U+002A
+ Star,
+
+ /// `+` U+002B
+ Plus,
+}
+
+/// Item within a list.
+#[derive(Debug, Clone, PartialEq)]
+pub struct ListItem {
+ /// Task‑list checkbox state (GFM task‑lists). `None` ⇒ not a task list.
+ pub task: Option<TaskState>,
+
+ /// Nested blocks inside the list item.
+ pub blocks: Vec<Block>,
+}
+
+/// State of a task‑list checkbox.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum TaskState {
+ /// Unchecked (GFM task‑list item)
+ Incomplete,
+
+ /// Checked (GFM task‑list item)
+ Complete,
+}
+
+// ——————————————————————————————————————————————————————————————————————————
+// Code blocks
+// ——————————————————————————————————————————————————————————————————————————
+
+/// Fenced or indented code block.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct CodeBlock {
+ /// Distinguishes indented vs fenced code and stores the *info string*.
+ pub kind: CodeBlockKind,
+
+ /// Literal text inside the code block **without** final newline trimming.
+ pub literal: String,
+}
+
+/// The concrete kind of a code block.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum CodeBlockKind {
+ /// Indented block (≥ 4 spaces or 1 tab per line).
+ Indented,
+
+ /// Fenced block with *optional* info string (language, etc.).
+ Fenced { info: Option<String> },
+}
+
+// ——————————————————————————————————————————————————————————————————————————
+// Link reference definitions
+// ——————————————————————————————————————————————————————————————————————————
+
+/// Link reference definition (GFM) with a label, destination and optional title.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct LinkDefinition {
+ /// Link label (acts as the *identifier*).
+ pub label: Vec<Inline>,
+
+ /// Link URL (absolute or relative) or email address.
+ pub destination: String,
+
+ /// Optional title (for links and images).
+ pub title: Option<String>,
+}
+
+// ——————————————————————————————————————————————————————————————————————————
+// Tables
+// ——————————————————————————————————————————————————————————————————————————
+
+/// A table is a collection of rows and columns with optional alignment.
+/// The first row is the header row.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct Table {
+ /// Each row is a vector of *cells*; header row is **row 0**.
+ pub rows: Vec<TableRow>,
+
+ /// Column alignment; `alignments.len() == column_count`.
+ pub alignments: Vec<Alignment>,
+}
+
+/// A table row is a vector of cells (columns).
+pub type TableRow = Vec<TableCell>;
+
+/// A table cell is a vector of inlines (text, links, etc.).
+pub type TableCell = Vec<Inline>;
+
+/// Specifies the alignment of a table cell.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
+pub enum Alignment {
+ /// No alignment specified
+ None,
+
+ /// Left aligned
+ #[default]
+ Left,
+
+ /// Right aligned
+ Center,
+
+ /// Right aligned
+ Right,
+}
+
+// ——————————————————————————————————————————————————————————————————————————
+// Footnotes
+// ——————————————————————————————————————————————————————————————————————————
+
+#[derive(Debug, Clone, PartialEq)]
+pub struct FootnoteDefinition {
+ /// Normalized label (without leading `^`).
+ pub label: String,
+
+ /// Footnote content (blocks).
+ pub blocks: Vec<Block>,
+}
+
+// ——————————————————————————————————————————————————————————————————————————
+// Callouts
+// ——————————————————————————————————————————————————————————————————————————
+
+#[derive(Debug, Clone, PartialEq)]
+pub struct Callout {
+ pub level: String,
+
+ pub title: Option<String>,
+
+ pub foldable: bool,
+
+ pub open: bool,
+
+ /// Footnote content (blocks).
+ pub blocks: Vec<Block>,
+}
+
+// ——————————————————————————————————————————————————————————————————————————
+// Inline‑level nodes
+// ——————————————————————————————————————————————————————————————————————————
+
+#[derive(Debug, Clone, PartialEq, Hash, Eq)]
+pub enum Inline {
+ /// Plain text (decoded entity references, preserved backslash escapes).
+ Text(String),
+
+ /// Hard line break
+ LineBreak,
+
+ /// Inline code span
+ Code(String),
+
+ /// Raw HTML fragment
+ Html(String),
+
+ /// Link to a destination with optional title.
+ Link(Link),
+
+ /// Reference link
+ LinkReference(LinkReference),
+
+ /// OFM: Tag (`#tag`)
+ Tag(Tag),
+ /// OFM: Wikilink (`[[Link]]` / `[[Link|Title]]`)
+ Wikilink(Wikilink),
+
+ /// Image with optional title.
+ Image(Image),
+
+ /// Emphasis (`*` / `_`)
+ Emphasis(Vec<Inline>),
+ /// Strong emphasis (`**` / `__`)
+ Strong(Vec<Inline>),
+ /// Strikethrough (`~~`)
+ Strikethrough(Vec<Inline>),
+
+ /// Autolink (`<https://>` or `<mailto:…>`)
+ Autolink(String),
+
+ /// Footnote reference (`[^label]`)
+ FootnoteReference(String),
+
+ /// Empty element. This is used to represent skipped elements in the AST.
+ Empty,
+}
+
+/// Re‑usable structure for links and images (destination + children).
+#[derive(Debug, Clone, PartialEq, Hash, Eq)]
+pub struct Link {
+ /// Destination URL (absolute or relative) or email address.
+ pub destination: String,
+
+ /// Optional title (for links and images).
+ pub title: Option<String>,
+
+ /// Inline content (text, code, etc.) inside the link or image.
+ pub children: Vec<Inline>,
+}
+
+/// Re‑usable structure for links and images (destination + children).
+#[derive(Debug, Clone, PartialEq, Hash, Eq)]
+pub struct Image {
+ /// Image URL (absolute or relative).
+ pub destination: String,
+
+ /// Optional title.
+ pub title: Option<String>,
+
+ /// Alternative text.
+ pub alt: String,
+}
+
+#[derive(Debug, Clone, PartialEq, Hash, Eq)]
+pub struct LinkReference {
+ /// Link label (acts as the *identifier*).
+ pub label: Vec<Inline>,
+
+ /// Link text
+ pub text: Vec<Inline>,
+}
+
+#[derive(Debug, Clone, PartialEq, Hash, Eq)]
+pub struct Tag {
+ /// Tag text
+ pub text: String,
+}
+
+#[derive(Debug, Clone, PartialEq, Hash, Eq)]
+pub struct Wikilink {
+ /// Wikilink link
+ pub link: String,
+
+ /// Wikilink target
+ pub target: Option<String>,
+
+ /// Wikilink name
+ pub name: Option<String>,
+}
diff --git a/sable-markdown/src/lib.rs b/sable-markdown/src/lib.rs
new file mode 100644
index 0000000..ebfb423
+#![deny(rust_2018_idioms, unsafe_code)]
+#![warn(
+ absolute_paths_not_starting_with_crate,
+ ambiguous_associated_items,
+ anonymous_parameters,
+ arithmetic_overflow,
+ array_into_iter,
+ asm_sub_register,
+ bad_asm_style,
+ bindings_with_variant_name,
+ break_with_label_and_loop,
+ clashing_extern_declarations,
+ coherence_leak_check,
+ conflicting_repr_hints,
+ confusable_idents,
+ const_evaluatable_unchecked,
+ const_item_mutation,
+ dangling_pointers_from_temporaries,
+ dead_code,
+ deprecated_in_future,
+ deprecated_where_clause_location,
+ deprecated,
+ deref_into_dyn_supertrait,
+ deref_nullptr,
+ drop_bounds,
+ duplicate_macro_attributes,
+ dyn_drop,
+ ellipsis_inclusive_range_patterns,
+ enum_intrinsics_non_enums,
+ explicit_outlives_requirements,
+ exported_private_dependencies,
+ forbidden_lint_groups,
+ function_item_references,
+ future_incompatible,
+ ill_formed_attribute_input,
+ improper_ctypes_definitions,
+ improper_ctypes,
+ incomplete_features,
+ incomplete_include,
+ ineffective_unstable_trait_impl,
+ inline_no_sanitize,
+ invalid_atomic_ordering,
+ invalid_doc_attributes,
+ invalid_type_param_default,
+ invalid_value,
+ irrefutable_let_patterns,
+ keyword_idents,
+ large_assignments,
+ late_bound_lifetime_arguments,
+ legacy_derive_helpers,
+ macro_expanded_macro_exports_accessed_by_absolute_paths,
+ meta_variable_misuse,
+ missing_abi,
+ missing_copy_implementations,
+ missing_debug_implementations,
+ missing_docs,
+ mixed_script_confusables,
+ mutable_transmutes,
+ named_arguments_used_positionally,
+ named_asm_labels,
+ no_mangle_const_items,
+ no_mangle_generic_items,
+ non_ascii_idents,
+ non_camel_case_types,
+ non_fmt_panics,
+ non_shorthand_field_patterns,
+ non_snake_case,
+ non_upper_case_globals,
+ nonstandard_style,
+ noop_method_call,
+ overflowing_literals,
+ overlapping_range_endpoints,
+ path_statements,
+ patterns_in_fns_without_body,
+ proc_macro_derive_resolution_fallback,
+ pub_use_of_private_extern_crate,
+ redundant_semicolons,
+ repr_transparent_external_private_fields,
+ rust_2021_incompatible_closure_captures,
+ rust_2021_incompatible_or_patterns,
+ rust_2021_prefixes_incompatible_syntax,
+ rust_2021_prelude_collisions,
+ semicolon_in_expressions_from_macros,
+ soft_unstable,
+ stable_features,
+ text_direction_codepoint_in_comment,
+ text_direction_codepoint_in_literal,
+ trivial_bounds,
+ trivial_casts,
+ trivial_numeric_casts,
+ type_alias_bounds,
+ tyvar_behind_raw_pointer,
+ uncommon_codepoints,
+ unconditional_panic,
+ unconditional_recursion,
+ unexpected_cfgs,
+ uninhabited_static,
+ unknown_crate_types,
+ unnameable_test_items,
+ unreachable_code,
+ unreachable_patterns,
+ unreachable_pub,
+ unsafe_op_in_unsafe_fn,
+ unstable_features,
+ unstable_name_collisions,
+ unused_allocation,
+ unused_assignments,
+ unused_attributes,
+ unused_braces,
+ unused_comparisons,
+ unused_crate_dependencies,
+ unused_doc_comments,
+ unused_extern_crates,
+ unused_features,
+ unused_import_braces,
+ unused_imports,
+ unused_labels,
+ unused_lifetimes,
+ unused_macro_rules,
+ unused_macros,
+ unused_must_use,
+ unused_mut,
+ unused_parens,
+ unused_qualifications,
+ unused_unsafe,
+ unused_variables,
+ useless_deprecated,
+ while_true
+)]
+#![warn(
+ clippy::all,
+ clippy::await_holding_lock,
+ clippy::char_lit_as_u8,
+ clippy::checked_conversions,
+ clippy::cognitive_complexity,
+ clippy::dbg_macro,
+ clippy::debug_assert_with_mut_call,
+ clippy::disallowed_script_idents,
+ clippy::doc_link_with_quotes,
+ clippy::doc_markdown,
+ clippy::empty_enum,
+ clippy::empty_line_after_outer_attr,
+ clippy::empty_structs_with_brackets,
+ clippy::enum_glob_use,
+ clippy::equatable_if_let,
+ clippy::exit,
+ clippy::expl_impl_clone_on_copy,
+ clippy::explicit_deref_methods,
+ clippy::explicit_into_iter_loop,
+ clippy::fallible_impl_from,
+ clippy::filter_map_next,
+ clippy::flat_map_option,
+ clippy::float_cmp_const,
+ clippy::float_cmp,
+ clippy::float_equality_without_abs,
+ clippy::fn_params_excessive_bools,
+ clippy::fn_to_numeric_cast_any,
+ clippy::from_iter_instead_of_collect,
+ clippy::if_let_mutex,
+ clippy::implicit_clone,
+ clippy::imprecise_flops,
+ clippy::index_refutable_slice,
+ clippy::inefficient_to_string,
+ clippy::invalid_upcast_comparisons,
+ clippy::iter_not_returning_iterator,
+ clippy::large_digit_groups,
+ clippy::large_stack_arrays,
+ clippy::large_types_passed_by_value,
+ clippy::let_unit_value,
+ clippy::linkedlist,
+ clippy::lossy_float_literal,
+ clippy::macro_use_imports,
+ clippy::manual_ok_or,
+ clippy::map_err_ignore,
+ clippy::map_flatten,
+ clippy::map_unwrap_or,
+ clippy::match_same_arms,
+ clippy::match_wild_err_arm,
+ clippy::match_wildcard_for_single_variants,
+ clippy::mem_forget,
+ clippy::missing_const_for_fn,
+ clippy::missing_enforced_import_renames,
+ clippy::missing_errors_doc,
+ clippy::missing_panics_doc,
+ clippy::mut_mut,
+ clippy::mutex_integer,
+ clippy::needless_borrow,
+ clippy::needless_continue,
+ clippy::needless_for_each,
+ clippy::needless_pass_by_value,
+ clippy::negative_feature_names,
+ clippy::nonstandard_macro_braces,
+ clippy::nursery,
+ clippy::option_if_let_else,
+ clippy::option_option,
+ clippy::path_buf_push_overwrite,
+ clippy::pedantic,
+ clippy::print_stderr,
+ clippy::print_stdout,
+ clippy::ptr_as_ptr,
+ clippy::rc_mutex,
+ clippy::ref_option_ref,
+ clippy::rest_pat_in_fully_bound_structs,
+ clippy::same_functions_in_if_condition,
+ clippy::semicolon_if_nothing_returned,
+ clippy::shadow_unrelated,
+ clippy::similar_names,
+ clippy::single_match_else,
+ clippy::string_add_assign,
+ clippy::string_add,
+ clippy::string_lit_as_bytes,
+ clippy::string_to_string,
+ clippy::suspicious_operation_groupings,
+ clippy::todo,
+ clippy::trailing_empty_array,
+ clippy::trait_duplication_in_bounds,
+ clippy::trivially_copy_pass_by_ref,
+ clippy::unimplemented,
+ clippy::unnecessary_wraps,
+ clippy::unnested_or_patterns,
+ clippy::unseparated_literal_suffix,
+ clippy::unused_self,
+ clippy::use_debug,
+ clippy::use_self,
+ clippy::used_underscore_binding,
+ clippy::useless_let_if_seq,
+ clippy::useless_transmute,
+ clippy::verbose_file_reads,
+ clippy::wildcard_dependencies,
+ clippy::wildcard_imports,
+ clippy::zero_sized_map_values
+)]
+
+pub mod ast;
+
+#[cfg(feature = "parser")]
+pub mod parser;
+
+#[cfg(feature = "render")]
+pub mod render;
diff --git a/sable-markdown/src/parser/blocks/blockquote.rs b/sable-markdown/src/parser/blocks/blockquote.rs
new file mode 100644
index 0000000..25edc8d
+use nom::{
+ IResult, Parser,
+ character::complete::char,
+ multi::{many_m_n, many1},
+ sequence::preceded,
+};
+
+use crate::{
+ ast::Block,
+ parser::util::{line_terminated, not_eof_or_eol0},
+};
+
+pub(super) fn blockquote<'a>() -> impl FnMut(&'a str) -> IResult<&'a str, Vec<Block>> {
+ move |input: &'a str| {
+ let prefix = preceded(many_m_n(0, 3, char(' ')), char('>'));
+
+ let (input, lines) =
+ many1(preceded(prefix, line_terminated(not_eof_or_eol0))).parse(input)?;
+ let inner = lines.join("\n");
+
+ let (_, inner) = many1(crate::parser::blocks::block())
+ .parse(&inner)
+ .map_err(|err| err.map_input(|_| input))?;
+
+ let inner = inner.into_iter().flatten().collect();
+
+ Ok((input, inner))
+ }
+}
diff --git a/sable-markdown/src/parser/blocks/callout.rs b/sable-markdown/src/parser/blocks/callout.rs
new file mode 100644
index 0000000..f5b1aa3
+use nom::{
+ IResult, Parser,
+ branch::alt,
+ bytes::complete::tag,
+ character::complete::{char, none_of},
+ combinator::{opt, recognize, value, verify},
+ multi::{many_m_n, many1},
+ sequence::preceded,
+};
+
+use crate::{
+ ast::Callout,
+ parser::util::{line_terminated, not_eof_or_eol0, not_eof_or_eol1},
+};
+
+pub(super) fn callout<'a>() -> impl FnMut(&'a str) -> IResult<&'a str, Callout> {
+ move |input: &'a str| {
+ let prefix = || preceded(many_m_n(0, 3, char(' ')), char('>'));
+
+ let (input, (level, kind, title)) =
+ preceded(prefix(), line_terminated(callout_header)).parse(input)?;
+
+ let (input, lines) =
+ many1(preceded(prefix(), line_terminated(not_eof_or_eol0))).parse(input)?;
+ let inner = lines.join("\n");
+
+ let (_, inner) = many1(crate::parser::blocks::block())
+ .parse(&inner)
+ .map_err(|err| err.map_input(|_| input))?;
+
+ let callout = Callout {
+ level,
+ title,
+ foldable: kind.is_some(),
+ open: kind.unwrap_or(true),
+ blocks: inner.into_iter().flatten().collect(),
+ };
+
+ Ok((input, callout))
+ }
+}
+
+fn callout_header(input: &str) -> IResult<&str, (String, Option<bool>, Option<String>)> {
+ let (input, _) = many_m_n(0, 3, char(' ')).parse(input)?;
+ let (input, _) = tag("[!").parse(input)?;
+ let (input, level) = recognize(many1(verify(none_of("]"), |c| *c != ']'))).parse(input)?;
+ let (input, _) = tag("]").parse(input)?;
+ let (input, kind) = opt(alt((value(true, tag("+")), value(false, tag("-"))))).parse(input)?;
+ let (input, title) = opt(callout_title).parse(input)?;
+
+ Ok((input, (level.to_owned(), kind, title)))
+}
+
+fn callout_title(input: &str) -> IResult<&str, String> {
+ let (input, _) = many_m_n(0, 3, char(' ')).parse(input)?;
+ let (input, label) = not_eof_or_eol1.parse(input)?;
+
+ Ok((input, label.to_owned()))
+}
diff --git a/sable-markdown/src/parser/blocks/code_block.rs b/sable-markdown/src/parser/blocks/code_block.rs
new file mode 100644
index 0000000..9d0b491
+use nom::{
+ IResult, Parser,
+ branch::alt,
+ bytes::complete::tag,
+ character::complete::char,
+ combinator::{not, opt, peek, recognize, value},
+ multi::{many_m_n, many0, many1},
+ sequence::preceded,
+};
+
+use crate::{
+ ast::{CodeBlock, CodeBlockKind},
+ parser::util::{line_terminated, not_eof_or_eol0, not_eof_or_eol1},
+};
+
+pub(super) fn code_block<'a>() -> impl FnMut(&'a str) -> IResult<&'a str, CodeBlock> {
+ move |input: &'a str| alt((code_block_indented(), code_block_fenced())).parse(input)
+}
+
+pub(super) fn code_block_indented<'a>() -> impl FnMut(&'a str) -> IResult<&'a str, CodeBlock> {
+ move |input: &'a str| {
+ let line_parser = preceded(
+ alt((value((), many_m_n(4, 4, char(' '))), value((), char('\t')))),
+ line_terminated(not_eof_or_eol0),
+ );
+
+ let (input, lines) = many1(line_parser).parse(input)?;
+ let literal = lines.join("\n");
+
+ let code_block = CodeBlock {
+ kind: CodeBlockKind::Indented,
+ literal,
+ };
+
+ Ok((input, code_block))
+ }
+}
+
+pub(super) fn code_block_fenced<'a>() -> impl FnMut(&'a str) -> IResult<&'a str, CodeBlock> {
+ move |input: &'a str| {
+ let (input, space_prefix) = many_m_n(0, 3, char(' ')).parse(input)?;
+ let prefix_length = space_prefix.len();
+
+ let (input, (fence, info)) = line_terminated((
+ recognize(alt((
+ many_m_n(3, usize::MAX, char('`')),
+ many_m_n(3, usize::MAX, char('~')),
+ ))),
+ opt(recognize(not_eof_or_eol1)),
+ ))
+ .parse(input)?;
+ let ending_fence = || {
+ line_terminated((
+ many_m_n(0, 3, char(' ')),
+ tag(fence),
+ many0(char(fence.chars().next().unwrap())),
+ ))
+ };
+
+ let (input, lines) = many0(preceded(
+ peek(not(ending_fence())),
+ preceded(
+ many_m_n(0, prefix_length, char(' ')),
+ line_terminated(not_eof_or_eol0),
+ ),
+ ))
+ .parse(input)?;
+ let (input, _) = ending_fence().parse(input)?;
+
+ let literal = lines.join("\n");
+ let code_block = CodeBlock {
+ kind: CodeBlockKind::Fenced {
+ info: info.map(ToOwned::to_owned),
+ },
+ literal,
+ };
+
+ Ok((input, code_block))
+ }
+}
diff --git a/sable-markdown/src/parser/blocks/footnote_definition.rs b/sable-markdown/src/parser/blocks/footnote_definition.rs
new file mode 100644
index 0000000..6ddfe69
+use nom::{
+ IResult, Parser,
+ bytes::complete::tag,
+ character::complete::{char, none_of},
+ combinator::{recognize, verify},
+ multi::{many_m_n, many0, many1},
+ sequence::preceded,
+};
+
+use crate::{
+ ast::FootnoteDefinition,
+ parser::util::{line_terminated, not_eof_or_eol1},
+};
+
+pub(super) fn footnote_definition<'a>()
+-> impl FnMut(&'a str) -> IResult<&'a str, FootnoteDefinition> {
+ move |input: &'a str| {
+ let (input, _) = many_m_n(0, 3, char(' ')).parse(input)?;
+ let (input, _) = tag("[^").parse(input)?;
+ let (input, label) = recognize(many1(verify(none_of("]"), |c| *c != ']'))).parse(input)?;
+ let (input, _) = tag("]:").parse(input)?;
+ let (input, _) = many_m_n(0, 3, char(' ')).parse(input)?;
+ let (input, first_line) = line_terminated(not_eof_or_eol1).parse(input)?;
+ let (input, rest_lines) = many0(preceded(
+ many_m_n(3, 3, char(' ')),
+ line_terminated(not_eof_or_eol1),
+ ))
+ .parse(input)?;
+
+ let total_size = first_line.len() + rest_lines.len();
+ let mut footnote_content = String::with_capacity(total_size);
+ if !first_line.is_empty() {
+ footnote_content.push_str(first_line);
+ }
+ for line in rest_lines {
+ footnote_content.push('\n');
+ footnote_content.push_str(line);
+ }
+
+ let (_, blocks) = many0(crate::parser::blocks::block())
+ .parse(&footnote_content)
+ .map_err(|err| err.map_input(|_| input))?;
+
+ let blocks = blocks.into_iter().flatten().collect();
+
+ let v = FootnoteDefinition {
+ label: label.to_owned(),
+ blocks,
+ };
+
+ Ok((input, v))
+ }
+}
diff --git a/sable-markdown/src/parser/blocks/heading.rs b/sable-markdown/src/parser/blocks/heading.rs
new file mode 100644
index 0000000..3b4a5eb
+use nom::{
+ IResult, Parser,
+ branch::alt,
+ character::complete::{char, space0, space1},
+ combinator::{opt, value},
+ multi::{many_m_n, many1},
+ sequence::{preceded, terminated},
+};
+
+use crate::{
+ ast::{Block, Heading, HeadingKind, SetextHeading},
+ parser::util::{line_terminated, not_eof_or_eol1},
+};
+
+/// Parse headings in format:
+/// ### Header text
+pub(super) fn heading_v1<'a>() -> impl FnMut(&'a str) -> IResult<&'a str, Heading> {
+ move |input: &'a str| {
+ let (input, (prefix, _, content)) = (
+ many_m_n(1, 6, char('#')),
+ space1,
+ line_terminated(not_eof_or_eol1),
+ )
+ .parse(input)?;
+
+ let (_, content) = crate::parser::inline::inline_many0().parse(content)?;
+
+ let heading = Heading {
+ kind: HeadingKind::Atx(prefix.len() as u8),
+ content,
+ };
+
+ Ok((input, heading))
+ }
+}
+
+/// Parse headings in format:
+/// Heading text
+/// ====
+pub(super) fn heading_v2_or_paragraph<'a>() -> impl FnMut(&'a str) -> IResult<&'a str, Block> {
+ move |input: &'a str| {
+ let (input, (content, level)) = (
+ crate::parser::blocks::paragraph::paragraph(true),
+ opt(heading_v2_level()),
+ )
+ .parse(input)?;
+
+ if let Some(level) = level {
+ let heading = Heading {
+ kind: HeadingKind::Setext(level),
+ content,
+ };
+ return Ok((input, Block::Heading(heading)));
+ }
+
+ Ok((input, Block::Paragraph(content)))
+ }
+}
+
+pub(super) fn heading_v2_level<'a>() -> impl FnMut(&'a str) -> IResult<&'a str, SetextHeading> {
+ move |input: &'a str| {
+ let setext_parser = alt((
+ value(SetextHeading::Level1, many1(char('='))),
+ value(SetextHeading::Level2, many1(char('-'))),
+ ));
+
+ let r = line_terminated(preceded(
+ many_m_n(0, 3, char(' ')),
+ terminated(setext_parser, space0),
+ ))
+ .parse(input)?;
+
+ Ok(r)
+ }
+}
diff --git a/sable-markdown/src/parser/blocks/html_block.rs b/sable-markdown/src/parser/blocks/html_block.rs
new file mode 100644
index 0000000..562bbdc
+use nom::{
+ IResult, Parser,
+ branch::alt,
+ bytes::complete::{tag, tag_no_case},
+ character::complete::{
+ alpha1, alphanumeric1, anychar, char, line_ending, one_of, satisfy, space0, space1,
+ },
+ combinator::{eof, not, opt, peek, recognize, value, verify},
+ multi::{many_m_n, many0, many1},
+ sequence::{delimited, pair, preceded, terminated},
+};
+
+pub(super) fn html_block() -> impl FnMut(&str) -> IResult<&str, &str> {
+ move |input: &str| {
+ alt((
+ html_block1(),
+ html_block2(),
+ html_block3(),
+ html_block4(),
+ html_block5(),
+ html_block6(),
+ html_block7(),
+ ))
+ .parse(input)
+ }
+}
+
+fn html_block1() -> impl FnMut(&str) -> IResult<&str, &str> {
+ move |input: &str| {
+ let tag_variant_parser = || {
+ alt((
+ tag_no_case("script"),
+ tag_no_case("pre"),
+ tag_no_case("style"),
+ ))
+ };
+
+ let end_parser = || delimited(tag("</"), tag_variant_parser(), char('>'));
+
+ preceded(
+ many_m_n(0, 3, char(' ')),
+ recognize((
+ char('<'),
+ tag_variant_parser(),
+ alt((
+ value((), char(' ')),
+ value((), char('>')),
+ value((), line_ending),
+ )),
+ many0(pair(peek(not(end_parser())), anychar)),
+ end_parser(),
+ )),
+ )
+ .parse(input)
+ }
+}
+
+fn html_block2() -> impl FnMut(&str) -> IResult<&str, &str> {
+ move |input: &str| {
+ preceded(
+ many_m_n(0, 3, char(' ')),
+ recognize((
+ tag("<!--"),
+ many0(pair(peek(not(tag("-->"))), anychar)),
+ tag("-->"),
+ )),
+ )
+ .parse(input)
+ }
+}
+
+fn html_block3() -> impl FnMut(&str) -> IResult<&str, &str> {
+ move |input: &str| {
+ preceded(
+ many_m_n(0, 3, char(' ')),
+ recognize((
+ tag("<?"),
+ many0(pair(peek(not(tag("?>"))), anychar)),
+ tag("?>"),
+ )),
+ )
+ .parse(input)
+ }
+}
+
+fn html_block4() -> impl FnMut(&str) -> IResult<&str, &str> {
+ move |input: &str| {
+ preceded(
+ many_m_n(0, 3, char(' ')),
+ recognize((
+ tag("<!"),
+ satisfy(|c| c.is_ascii_uppercase()),
+ many0(pair(peek(not(char('>'))), anychar)),
+ tag(">"),
+ )),
+ )
+ .parse(input)
+ }
+}
+
+fn html_block5() -> impl FnMut(&str) -> IResult<&str, &str> {
+ move |input: &str| {
+ preceded(
+ many_m_n(0, 3, char(' ')),
+ recognize((
+ tag("<![CDATA["),
+ many0(pair(peek(not(tag("]]>"))), anychar)),
+ tag("]]>"),
+ )),
+ )
+ .parse(input)
+ }
+}
+
+fn html_block6() -> impl FnMut(&str) -> IResult<&str, &str> {
+ move |input: &str| {
+ let tag_variant = alt((
+ alt((
+ tag_no_case("address"),
+ tag_no_case("article"),
+ tag_no_case("aside"),
+ tag_no_case("a"),
+ tag_no_case("base"),
+ tag_no_case("basefont"),
+ tag_no_case("blockquote"),
+ tag_no_case("body"),
+ tag_no_case("caption"),
+ tag_no_case("center"),
+ tag_no_case("col"),
+ tag_no_case("colgroup"),
+ )),
+ alt((
+ tag_no_case("dd"),
+ tag_no_case("details"),
+ tag_no_case("dialog"),
+ tag_no_case("dir"),
+ tag_no_case("div"),
+ tag_no_case("dl"),
+ tag_no_case("dt"),
+ tag_no_case("fieldset"),
+ tag_no_case("figcaption"),
+ tag_no_case("figure"),
+ tag_no_case("footer"),
+ tag_no_case("form"),
+ tag_no_case("frame"),
+ tag_no_case("frameset"),
+ )),
+ alt((
+ tag_no_case("h1"),
+ tag_no_case("h2"),
+ tag_no_case("h3"),
+ tag_no_case("h4"),
+ tag_no_case("h5"),
+ tag_no_case("h6"),
+ tag_no_case("head"),
+ tag_no_case("header"),
+ tag_no_case("hr"),
+ tag_no_case("html"),
+ tag_no_case("iframe"),
+ tag_no_case("legend"),
+ )),
+ alt((
+ tag_no_case("li"),
+ tag_no_case("link"),
+ tag_no_case("main"),
+ tag_no_case("menu"),
+ tag_no_case("menuitem"),
+ tag_no_case("nav"),
+ tag_no_case("noframes"),
+ tag_no_case("ol"),
+ tag_no_case("optgroup"),
+ tag_no_case("option"),
+ tag_no_case("p"),
+ tag_no_case("param"),
+ )),
+ alt((
+ tag_no_case("section"),
+ tag_no_case("source"),
+ tag_no_case("span"),
+ tag_no_case("summary"),
+ tag_no_case("table"),
+ tag_no_case("tbody"),
+ tag_no_case("td"),
+ tag_no_case("tfoot"),
+ tag_no_case("th"),
+ tag_no_case("thead"),
+ tag_no_case("title"),
+ tag_no_case("tr"),
+ tag_no_case("track"),
+ tag_no_case("ul"),
+ )),
+ ));
+ let end_parser = || {
+ alt((
+ value((), terminated(line_ending, (space0, line_ending))),
+ value((), eof),
+ ))
+ };
+
+ preceded(
+ many_m_n(0, 3, char(' ')),
+ recognize((
+ alt((value((), tag("</")), value((), char('<')))),
+ tag_variant,
+ alt((
+ value((), char(' ')),
+ value((), line_ending),
+ value((), tag("/>")),
+ value((), char('>')),
+ )),
+ many0(pair(peek(not(end_parser())), anychar)),
+ opt(line_ending),
+ )),
+ )
+ .parse(input)
+ }
+}
+
+fn html_block7() -> impl FnMut(&str) -> IResult<&str, &str> {
+ move |input: &str| {
+ let end_parser = || {
+ alt((
+ value((), (line_ending, space0, line_ending)),
+ value((), eof),
+ ))
+ };
+
+ preceded(
+ many_m_n(0, 3, char(' ')),
+ recognize((
+ alt((
+ complete_open_html_tag(&["script", "pre", "style"]),
+ complete_closing_html_tag,
+ )),
+ alt((value((), line_ending), value((), char(' ')))),
+ many0(pair(peek(not(end_parser())), anychar)),
+ end_parser(),
+ )),
+ )
+ .parse(input)
+ }
+}
+
+fn complete_open_html_tag(
+ restricted_tags: &'static [&'static str],
+) -> impl FnMut(&str) -> IResult<&str, &str> {
+ move |input: &str| {
+ recognize((
+ char('<'),
+ verify(html_tag_name, |s: &str| {
+ !restricted_tags
+ .iter()
+ .any(|tag| tag.eq_ignore_ascii_case(s))
+ }),
+ many0(html_tag_attribute),
+ space0,
+ opt(char('/')),
+ char('>'),
+ ))
+ .parse(input)
+ }
+}
+
+fn complete_closing_html_tag(input: &str) -> IResult<&str, &str> {
+ recognize((tag("</"), html_tag_name, space0, char('>'))).parse(input)
+}
+
+fn html_tag_name(input: &str) -> IResult<&str, &str> {
+ recognize((
+ alpha1,
+ many0(alt((value((), char('-')), value((), alphanumeric1)))),
+ ))
+ .parse(input)
+}
+
+fn html_tag_attribute(input: &str) -> IResult<&str, &str> {
+ recognize((
+ space1,
+ html_tag_attribute_name,
+ opt(html_tag_attribute_value_specification),
+ ))
+ .parse(input)
+}
+
+fn html_tag_attribute_name(input: &str) -> IResult<&str, &str> {
+ recognize((
+ alt((value((), alpha1), value((), one_of("_:")))),
+ many0(alt((value((), one_of("_.:-")), value((), alphanumeric1)))),
+ ))
+ .parse(input)
+}
+
+fn html_tag_attribute_value_specification(input: &str) -> IResult<&str, &str> {
+ recognize((space0, char('='), space0, html_tag_attribute_value)).parse(input)
+}
+
+fn html_tag_attribute_value(input: &str) -> IResult<&str, &str> {
+ alt((
+ html_tag_attribute_value_unquoted,
+ html_tag_attribute_value_single_quoted,
+ html_tag_attribute_value_double_quoted,
+ ))
+ .parse(input)
+}
+
+fn html_tag_attribute_value_unquoted(input: &str) -> IResult<&str, &str> {
+ recognize(many1(pair(
+ peek(not(alt((value((), space1), value((), one_of("\"'=<>`")))))),
+ anychar,
+ )))
+ .parse(input)
+}
+
+fn html_tag_attribute_value_single_quoted(input: &str) -> IResult<&str, &str> {
+ recognize(delimited(
+ char('\''),
+ pair(peek(not(char('\''))), anychar),
+ char('\''),
+ ))
+ .parse(input)
+}
+
+fn html_tag_attribute_value_double_quoted(input: &str) -> IResult<&str, &str> {
+ recognize(delimited(
+ char('"'),
+ pair(peek(not(char('"'))), anychar),
+ char('"'),
+ ))
+ .parse(input)
+}
diff --git a/sable-markdown/src/parser/blocks/link_definition.rs b/sable-markdown/src/parser/blocks/link_definition.rs
new file mode 100644
index 0000000..41a3b42
+use nom::{
+ IResult, Parser,
+ branch::alt,
+ character::complete::{char, line_ending, space0, space1},
+ combinator::{opt, recognize, verify},
+ multi::{many_m_n, many1},
+ sequence::preceded,
+};
+
+use crate::{
+ ast::LinkDefinition,
+ parser::link_util::{link_destination, link_label, link_title},
+};
+
+use super::eof_or_eol;
+
+pub(super) fn link_definition<'a>() -> impl FnMut(&'a str) -> IResult<&'a str, LinkDefinition> {
+ move |input: &'a str| {
+ let mut one_line_whitespace0 = (space0, opt(line_ending), space0);
+ let one_line_whitespace1 = verify(
+ recognize(many1(alt((line_ending, space1)))),
+ |chars: &str| {
+ let mut newlines = 0;
+ for ch in chars.chars() {
+ if ch == '\n' {
+ newlines += 1;
+ }
+ }
+ newlines <= 1
+ },
+ );
+
+ let (input, label) = preceded(many_m_n(0, 3, char(' ')), link_label()).parse(input)?;
+ let (input, _) = char(':').parse(input)?;
+ let (input, _) = one_line_whitespace0.parse(input)?;
+ let (input, destination) = link_destination.parse(input)?;
+ let (input, title) = opt(preceded(one_line_whitespace1, link_title)).parse(input)?;
+ let (input, _) = eof_or_eol.parse(input)?;
+
+ let v = LinkDefinition {
+ label,
+ destination,
+ title,
+ };
+
+ Ok((input, v))
+ }
+}
diff --git a/sable-markdown/src/parser/blocks/list.rs b/sable-markdown/src/parser/blocks/list.rs
new file mode 100644
index 0000000..7cf462e
+use nom::{
+ IResult, Parser,
+ branch::alt,
+ character::complete::{char, one_of, space0},
+ combinator::{map, not, opt, peek, recognize, value, verify},
+ multi::{many_m_n, many0, many1},
+ sequence::{delimited, preceded, terminated},
+};
+
+use crate::{
+ ast::{ListBulletKind, ListItem, ListKind, ListOrderedKindOptions, TaskState},
+ parser::util::{line_terminated, not_eof_or_eol0, not_eof_or_eol1},
+};
+
+fn list_item_task_state(input: &str) -> IResult<&str, TaskState> {
+ delimited(
+ char('['),
+ alt((
+ value(TaskState::Complete, one_of("xX")),
+ value(TaskState::Incomplete, char(' ')),
+ )),
+ char(']'),
+ )
+ .parse(input)
+}
+
+fn list_marker(input: &str) -> IResult<&str, ListKind> {
+ alt((
+ list_marker_ordered,
+ list_marker_star,
+ list_marker_plus,
+ list_marker_dash,
+ ))
+ .parse(input)
+}
+
+fn list_marker_star(input: &str) -> IResult<&str, ListKind> {
+ map(char('*'), |_| ListKind::Bullet(ListBulletKind::Star)).parse(input)
+}
+
+fn list_marker_plus(input: &str) -> IResult<&str, ListKind> {
+ map(char('+'), |_| ListKind::Bullet(ListBulletKind::Plus)).parse(input)
+}
+
+fn list_marker_dash(input: &str) -> IResult<&str, ListKind> {
+ map(char('-'), |_| ListKind::Bullet(ListBulletKind::Dash)).parse(input)
+}
+
+fn list_marker_ordered(input: &str) -> IResult<&str, ListKind> {
+ map(
+ terminated(nom::character::complete::u64, one_of(".)")),
+ |start| ListKind::Ordered(ListOrderedKindOptions { start }),
+ )
+ .parse(input)
+}
+
+fn list_marker_followed_by_spaces(
+ input: &str,
+) -> IResult<&str, (ListKind, usize, Option<TaskState>)> {
+ let (remaining, kind) = delimited(
+ many_m_n(0, 3, char(' ')),
+ list_marker,
+ many_m_n(1, 4, char(' ')),
+ )
+ .parse(input)?;
+
+ let consumed = input.len() - remaining.len();
+
+ let (input, task_state) = opt(terminated(list_item_task_state, char(' '))).parse(remaining)?;
+
+ Ok((input, (kind, consumed, task_state)))
+}
+
+fn list_marker_followed_by_newline(
+ input: &str,
+) -> IResult<&str, (ListKind, usize, Option<TaskState>)> {
+ let (remaining, kind) = preceded(many_m_n(0, 3, char(' ')), list_marker).parse(input)?;
+
+ // Cases:
+ // 1.
+ // 1.____
+ if let Ok((tail, _)) = line_terminated(space0).parse(remaining) {
+ // Calculate prefix length: consumed + 1 space
+ let consumed = input.len() - remaining.len() + 1;
+
+ return Ok((tail, (kind, consumed, None)));
+ }
+
+ let (remaining, _) = many_m_n(0, 3, char(' ')).parse(remaining)?;
+ let consumed = input.len() - remaining.len() + 1;
+
+ let (remaining, task_state) = line_terminated(list_item_task_state).parse(remaining)?;
+
+ Ok((remaining, (kind, consumed, Some(task_state))))
+}
+
+pub(super) fn list_marker_with_span_size(
+ input: &str,
+) -> IResult<&str, (ListKind, usize, Option<TaskState>, String)> {
+ alt((
+ map(
+ list_marker_followed_by_newline,
+ |(list_kind, prefix_length, task_state)| {
+ (list_kind, prefix_length, task_state, String::new())
+ },
+ ),
+ (map(
+ (
+ list_marker_followed_by_spaces,
+ line_terminated(not_eof_or_eol0),
+ ),
+ |((list_kind, prefix_length, task_state), s)| {
+ (list_kind, prefix_length, task_state, s.to_string())
+ },
+ )),
+ ))
+ .parse(input)
+}
+
+fn list_item_rest_line(
+ list_kind: ListKind,
+ prefix_length: usize,
+) -> impl FnMut(&str) -> IResult<&str, Vec<&str>> {
+ move |input: &str| {
+ // Stop parsing lines on EOF
+ if input.is_empty() {
+ return Err(nom::Err::Error(nom::error::Error::new(
+ input,
+ nom::error::ErrorKind::Eof,
+ )));
+ }
+
+ let marker_parser = match list_kind {
+ ListKind::Ordered(_) => list_marker_ordered,
+ ListKind::Bullet(ListBulletKind::Star) => list_marker_star,
+ ListKind::Bullet(ListBulletKind::Plus) => list_marker_plus,
+ ListKind::Bullet(ListBulletKind::Dash) => list_marker_dash,
+ };
+
+ line_terminated(preceded(
+ peek(not(alt((
+ value((), crate::parser::blocks::thematic_break::thematic_break()),
+ value(
+ (),
+ (
+ verify(
+ recognize(many_m_n(0, prefix_length, char(' '))),
+ |indent: &str| indent.len() < prefix_length,
+ ),
+ marker_parser,
+ ),
+ ),
+ )))),
+ alt((
+ // If starts with 0 <= prefix_length spaces
+ preceded(
+ many_m_n(0, prefix_length, char(' ')),
+ map(not_eof_or_eol1, |v| vec![v]),
+ ),
+ // If this is empty line, followed by prefix_length spaces
+ map(
+ (
+ recognize(many1(line_terminated(space0))),
+ preceded(
+ many_m_n(prefix_length, prefix_length, char(' ')),
+ not_eof_or_eol1,
+ ),
+ ),
+ |(newlines, content)| vec![newlines, content],
+ ),
+ )),
+ ))
+ .parse(input)
+ }
+}
+
+fn list_item_lines(
+ list_kind: ListKind,
+ prefix_length: usize,
+) -> impl FnMut(&str) -> IResult<&str, Vec<Vec<&str>>> {
+ move |input: &str| many0(list_item_rest_line(list_kind.clone(), prefix_length)).parse(input)
+}
+
+pub(super) fn list_item() -> impl FnMut(&str) -> IResult<&str, (ListKind, ListItem)> {
+ move |input: &str| {
+ let (input, (list_kind, item_prefix_length, task_state, first_line)) =
+ list_marker_with_span_size(input)?;
+
+ let (input, rest_lines) =
+ list_item_lines(list_kind.clone(), item_prefix_length).parse(input)?;
+
+ let total_size = first_line.len() + rest_lines.len();
+ let mut item_content = String::with_capacity(total_size);
+ if !first_line.is_empty() {
+ item_content.push_str(&first_line);
+ }
+ for line in rest_lines {
+ item_content.push('\n');
+ for subline in line {
+ item_content.push_str(subline);
+ }
+ }
+
+ let (_, blocks) = many0(crate::parser::blocks::block())
+ .parse(&item_content)
+ .map_err(|err| err.map_input(|_| input))?;
+
+ let blocks = blocks.into_iter().flatten().collect();
+
+ let item = ListItem {
+ task: task_state,
+ blocks,
+ };
+ Ok((input, (list_kind, item)))
+ }
+}
+
+pub(super) fn list() -> impl FnMut(&str) -> IResult<&str, crate::ast::List> {
+ move |input: &str| {
+ let (input, items) = many1(list_item()).parse(input)?;
+
+ // With many1(), first element always present
+ let first_item = items.first().unwrap();
+
+ let list = crate::ast::List {
+ kind: first_item.0.clone(),
+ items: items.into_iter().map(|(_, item)| item).collect(),
+ };
+
+ Ok((input, list))
+ }
+}
diff --git a/sable-markdown/src/parser/blocks/mod.rs b/sable-markdown/src/parser/blocks/mod.rs
new file mode 100644
index 0000000..9bc8a60
+mod blockquote;
+mod callout;
+mod code_block;
+mod footnote_definition;
+mod heading;
+mod html_block;
+mod link_definition;
+mod list;
+mod paragraph;
+mod table;
+mod thematic_break;
+
+#[cfg(test)]
+mod tests;
+
+use nom::{IResult, Parser, branch::alt, combinator::map, sequence::preceded};
+
+use crate::{
+ ast::Block,
+ parser::util::{eof_or_eol, line_terminated, many_empty_lines0},
+};
+
+pub(super) fn block<'a>() -> impl FnMut(&'a str) -> IResult<&'a str, Vec<Block>> {
+ move |input: &'a str| {
+ preceded(
+ many_empty_lines0,
+ alt((
+ map(code_block::code_block(), Block::CodeBlock),
+ map(heading::heading_v1(), Block::Heading),
+ heading::heading_v2_or_paragraph(),
+ map(thematic_break::thematic_break(), |()| Block::ThematicBreak),
+ map(callout::callout(), Block::Callout),
+ map(blockquote::blockquote(), Block::BlockQuote),
+ map(list::list(), Block::List),
+ map(html_block::html_block(), |s| Block::HtmlBlock(s.to_owned())),
+ // Alway try before link definition
+ map(
+ footnote_definition::footnote_definition(),
+ Block::FootnoteDefinition,
+ ),
+ map(link_definition::link_definition(), Block::Definition),
+ map(table::table(), Block::Table),
+ map(paragraph::paragraph(false), Block::Paragraph),
+ ))
+ .map(|v| vec![v]),
+ )
+ .parse(input)
+ }
+}
diff --git a/sable-markdown/src/parser/blocks/paragraph.rs b/sable-markdown/src/parser/blocks/paragraph.rs
new file mode 100644
index 0000000..e7d1a82
+use nom::{
+ IResult, Parser,
+ branch::alt,
+ character::complete::{char, line_ending, space0},
+ combinator::{not, peek, value},
+ multi::{many_m_n, separated_list0},
+ sequence::preceded,
+};
+
+use crate::{
+ ast::Inline,
+ parser::util::{line_terminated, not_eof_or_eol1},
+};
+
+pub(super) fn paragraph<'a>(
+ check_first_line: bool,
+) -> impl FnMut(&'a str) -> IResult<&'a str, Vec<Inline>> {
+ move |input: &'a str| {
+ let mut lines = Vec::new();
+ let input = if check_first_line {
+ input
+ } else {
+ // Skip checks for the first line, just make it a paragraph
+ let (input, first_line) =
+ preceded(many_m_n(0, 3, char(' ')), not_eof_or_eol1).parse(input)?;
+ lines.push(first_line);
+ input
+ };
+
+ let paragraph_parser = separated_list0(
+ line_ending,
+ preceded(
+ is_paragraph_line_start(),
+ preceded(many_m_n(0, 3, char(' ')), not_eof_or_eol1),
+ ),
+ );
+ let (input, rest_lines) = line_terminated(paragraph_parser).parse(input)?;
+ lines.extend(rest_lines);
+
+ let content = lines.join("\n");
+
+ let (_, content) = crate::parser::inline::inline_many1()
+ .parse(content.as_str())
+ .map_err(|err| err.map_input(|_| input))?;
+
+ Ok((input, content))
+ }
+}
+
+pub(super) fn is_paragraph_line_start<'a>() -> impl FnMut(&'a str) -> IResult<&'a str, ()> {
+ move |input: &'a str| {
+ peek(not(alt((
+ value((), crate::parser::blocks::heading::heading_v1()),
+ value((), crate::parser::blocks::heading::heading_v2_level()),
+ crate::parser::blocks::thematic_break::thematic_break(),
+ value((), crate::parser::blocks::blockquote::blockquote()),
+ value((), crate::parser::blocks::list::list_item()),
+ value((), crate::parser::blocks::code_block::code_block_fenced()),
+ value((), crate::parser::blocks::html_block::html_block()),
+ value(
+ (),
+ crate::parser::blocks::link_definition::link_definition(),
+ ),
+ value(
+ (),
+ crate::parser::blocks::footnote_definition::footnote_definition(),
+ ),
+ value((), crate::parser::blocks::table::table()),
+ value((), line_terminated(space0)),
+ ))))
+ .parse(input)
+ }
+}
diff --git a/sable-markdown/src/parser/blocks/table.rs b/sable-markdown/src/parser/blocks/table.rs
new file mode 100644
index 0000000..4f87f11
+use nom::{
+ IResult, Parser,
+ branch::alt,
+ bytes::complete::tag,
+ character::complete::{anychar, char, space0},
+ combinator::{map, not, opt, recognize, value},
+ multi::{many_m_n, many0, many1, separated_list1},
+ sequence::{delimited, preceded, terminated},
+};
+
+use crate::ast::{Alignment, Inline, Table, TableRow};
+
+use super::{eof_or_eol, line_terminated};
+
+pub(super) fn table<'a>() -> impl FnMut(&'a str) -> IResult<&'a str, Table> {
+ move |input: &'a str| {
+ let (input, header) = parse_table_row().parse(input)?;
+ let col_count = header.len();
+
+ let (input, alignments) = parse_alignment_row.parse(input)?;
+ if alignments.len() != col_count {
+ return Err(nom::Err::Error(nom::error::Error::new(
+ input,
+ nom::error::ErrorKind::Verify,
+ )));
+ }
+
+ let (input, rows) = parse_table_data_rows(col_count).parse(input)?;
+
+ Ok((
+ input,
+ Table {
+ rows: std::iter::once(header).chain(rows).collect(),
+ alignments,
+ },
+ ))
+ }
+}
+
+fn parse_table_data_rows<'a>(
+ col_count: usize,
+) -> impl FnMut(&'a str) -> IResult<&'a str, Vec<TableRow>> {
+ move |input: &'a str| {
+ many0(map(parse_table_row(), move |mut row| {
+ match row.len().cmp(&col_count) {
+ std::cmp::Ordering::Less => {
+ row.extend(
+ (0..(col_count - row.len())).map(|_| vec![Inline::Text(String::new())]),
+ );
+ }
+ std::cmp::Ordering::Greater => {
+ row.truncate(col_count);
+ }
+ std::cmp::Ordering::Equal => {}
+ }
+ row
+ }))
+ .parse(input)
+ }
+}
+
+fn parse_alignment_row(input: &str) -> IResult<&str, Vec<Alignment>> {
+ fn parse_cell_alignment(cell: &str) -> Alignment {
+ let trimmed = cell.trim();
+ let starts_with_colon = trimmed.starts_with(':');
+ let ends_with_colon = trimmed.ends_with(':');
+
+ match (starts_with_colon, ends_with_colon) {
+ (true, true) => Alignment::Center,
+ (true, false) => Alignment::Left,
+ (false, true) => Alignment::Right,
+ (false, false) => Alignment::None,
+ }
+ }
+
+ let alignment_parser = delimited(
+ space0,
+ alt((
+ recognize(delimited(char(':'), many1(char('-')), char(':'))),
+ recognize(preceded(char(':'), many1(char('-')))),
+ recognize(terminated(many1(char('-')), char(':'))),
+ recognize(many1(char('-'))),
+ )),
+ space0,
+ );
+
+ line_terminated(preceded(
+ many_m_n(0, 3, char(' ')),
+ delimited(
+ char('|'),
+ separated_list1(char('|'), map(alignment_parser, parse_cell_alignment)),
+ opt(char('|')),
+ ),
+ ))
+ .parse(input)
+}
+
+fn parse_table_row<'a>() -> impl FnMut(&'a str) -> IResult<&'a str, TableRow> {
+ move |input: &'a str| {
+ line_terminated(preceded(
+ many_m_n(0, 3, char(' ')),
+ delimited(
+ char('|'),
+ separated_list1(char('|'), cell_content()),
+ char('|'),
+ ),
+ ))
+ .parse(input)
+ }
+}
+
+fn cell_content<'a>() -> impl FnMut(&'a str) -> IResult<&'a str, Vec<Inline>> {
+ move |input: &'a str| {
+ let (input, chars) = many1(preceded(
+ not(alt((value((), eof_or_eol), value((), char('|'))))),
+ alt((value('|', tag("\\|")), anychar)),
+ ))
+ .parse(input)?;
+
+ let content = chars.iter().collect::<String>();
+ let trimmed_content = content.trim();
+ let (_, content) = crate::parser::inline::inline_many0()
+ .parse(trimmed_content)
+ .map_err(|err| err.map_input(|_| input))?;
+
+ Ok((input, content))
+ }
+}
diff --git a/sable-markdown/src/parser/blocks/tests/blockquote.rs b/sable-markdown/src/parser/blocks/tests/blockquote.rs
new file mode 100644
index 0000000..bb5b623
+use crate::ast::*;
+use crate::parser::parse_markdown;
+
+#[test]
+fn blockquote1() {
+ let doc = parse_markdown("> a\n>\n>> b").unwrap();
+ assert_eq!(
+ doc,
+ Document {
+ blocks: vec![Block::BlockQuote(vec![
+ Block::Paragraph(vec![Inline::Text("a".to_owned())]),
+ Block::BlockQuote(vec![Block::Paragraph(vec![Inline::Text("b".to_owned())])])
+ ])]
+ }
+ );
+}
+
+#[test]
+fn blockquote2() {
+ let doc = parse_markdown(">> a\n>>\n>> b").unwrap();
+ assert_eq!(
+ doc,
+ Document {
+ blocks: vec![Block::BlockQuote(vec![Block::BlockQuote(vec![
+ Block::Paragraph(vec![Inline::Text("a".to_owned()),]),
+ Block::Paragraph(vec![Inline::Text("b".to_owned())])
+ ])])]
+ }
+ );
+}
+
+// #[test]
+// fn blockquote_skip1() {
+// let config =
+// MarkdownParserConfig::default().with_block_blockquote_behavior(ElementBehavior::Skip);
+// let doc = parse_markdown(MarkdownParserState::with_config(config), "> a\n>\n>> b").unwrap();
+// assert_eq!(
+// doc,
+// Document {
+// blocks: vec![Block::Empty]
+// }
+// );
+// }
+
+// #[test]
+// fn blockquote_skip2() {
+// let config =
+// MarkdownParserConfig::default().with_block_blockquote_behavior(ElementBehavior::Skip);
+// let doc = parse_markdown(MarkdownParserState::with_config(config), "a\n> a\n>\n>> b").unwrap();
+// assert_eq!(
+// doc,
+// Document {
+// blocks: vec![
+// Block::Paragraph(vec![Inline::Text("a".to_owned())]),
+// Block::Empty
+// ]
+// }
+// );
+// }
+
+// #[test]
+// fn blockquote_ignore1() {
+// let config =
+// MarkdownParserConfig::default().with_block_blockquote_behavior(ElementBehavior::Ignore);
+// let doc = parse_markdown(MarkdownParserState::with_config(config), "> a\n>\n>> b").unwrap();
+// assert_eq!(
+// doc,
+// Document {
+// blocks: vec![Block::Paragraph(vec![Inline::Text(
+// "> a\n>\n>> b".to_owned()
+// )]),]
+// }
+// );
+// }
+
+// #[test]
+// fn blockquote_ignore2() {
+// let config =
+// MarkdownParserConfig::default().with_block_blockquote_behavior(ElementBehavior::Ignore);
+// let doc = parse_markdown(MarkdownParserState::with_config(config), "a\n> a\n>\n>> b").unwrap();
+// assert_eq!(
+// doc,
+// Document {
+// blocks: vec![Block::Paragraph(vec![Inline::Text(
+// "a\n> a\n>\n>> b".to_owned()
+// )]),]
+// }
+// );
+// }
diff --git a/sable-markdown/src/parser/blocks/tests/callout.rs b/sable-markdown/src/parser/blocks/tests/callout.rs
new file mode 100644
index 0000000..42d706f
+use crate::ast::*;
+use crate::parser::parse_markdown;
+
+#[test]
+fn callout() {
+ let doc = parse_markdown("> [!info]\n> a\n").unwrap();
+ assert_eq!(
+ doc,
+ Document {
+ blocks: vec![Block::Callout(Callout {
+ level: "info".to_owned(),
+ title: None,
+ foldable: false,
+ open: true,
+ blocks: vec![Block::Paragraph(vec![Inline::Text("a".to_owned())]),]
+ })]
+ }
+ );
+}
+
+#[test]
+fn callout_title() {
+ let doc = parse_markdown("> [!info] hello\n> a\n").unwrap();
+ assert_eq!(
+ doc,
+ Document {
+ blocks: vec![Block::Callout(Callout {
+ level: "info".to_owned(),
+ title: Some("hello".to_owned()),
+ foldable: false,
+ open: true,
+ blocks: vec![Block::Paragraph(vec![Inline::Text("a".to_owned())]),]
+ })]
+ }
+ );
+}
+
+#[test]
+fn callout_nested() {
+ let doc = parse_markdown("> [!info]\n>\n> > [!warning]\n> >\n> > nested\n").unwrap();
+ assert_eq!(
+ doc,
+ Document {
+ blocks: vec![Block::Callout(Callout {
+ level: "info".to_owned(),
+ title: None,
+ foldable: false,
+ open: true,
+ blocks: vec![Block::Callout(Callout {
+ level: "warning".to_owned(),
+ title: None,
+ foldable: false,
+ open: true,
+ blocks: vec![Block::Paragraph(vec![Inline::Text("nested".to_owned())]),]
+ })]
+ })]
+ }
+ );
+}
+
+#[test]
+fn callout_nested_title() {
+ let doc = parse_markdown("> [!info]\n>\n> > [!warning] oops\n> >\n> > nested\n").unwrap();
+ assert_eq!(
+ doc,
+ Document {
+ blocks: vec![Block::Callout(Callout {
+ level: "info".to_owned(),
+ title: None,
+ foldable: false,
+ open: true,
+ blocks: vec![Block::Callout(Callout {
+ level: "warning".to_owned(),
+ title: Some("oops".to_owned()),
+ foldable: false,
+ open: true,
+ blocks: vec![Block::Paragraph(vec![Inline::Text("nested".to_owned())]),]
+ })]
+ })]
+ }
+ );
+}
+
+#[test]
+fn callout_foldable_open() {
+ let doc = parse_markdown("> [!info]+\n> a\n").unwrap();
+ assert_eq!(
+ doc,
+ Document {
+ blocks: vec![Block::Callout(Callout {
+ level: "info".to_owned(),
+ title: None,
+ foldable: true,
+ open: true,
+ blocks: vec![Block::Paragraph(vec![Inline::Text("a".to_owned())]),]
+ })]
+ }
+ );
+}
+
+#[test]
+fn callout_foldable_open_title() {
+ let doc = parse_markdown("> [!info]+ hello\n> a\n").unwrap();
+ assert_eq!(
+ doc,
+ Document {
+ blocks: vec![Block::Callout(Callout {
+ level: "info".to_owned(),
+ title: Some("hello".to_owned()),
+ foldable: true,
+ open: true,
+ blocks: vec![Block::Paragraph(vec![Inline::Text("a".to_owned())]),]
+ })]
+ }
+ );
+}
+
+#[test]
+fn callout_foldable_closed() {
+ let doc = parse_markdown("> [!info]-\n> a\n").unwrap();
+ assert_eq!(
+ doc,
+ Document {
+ blocks: vec![Block::Callout(Callout {
+ level: "info".to_owned(),
+ title: None,
+ foldable: true,
+ open: false,
+ blocks: vec![Block::Paragraph(vec![Inline::Text("a".to_owned())]),]
+ })]
+ }
+ );
+}
+
+#[test]
+fn callout_foldable_closed_title() {
+ let doc = parse_markdown("> [!info]- hello\n> a\n").unwrap();
+ assert_eq!(
+ doc,
+ Document {
+ blocks: vec![Block::Callout(Callout {
+ level: "info".to_owned(),
+ title: Some("hello".to_owned()),
+ foldable: true,
+ open: false,
+ blocks: vec![Block::Paragraph(vec![Inline::Text("a".to_owned())]),]
+ })]
+ }
+ );
+}
diff --git a/sable-markdown/src/parser/blocks/tests/code_block.rs b/sable-markdown/src/parser/blocks/tests/code_block.rs
new file mode 100644
index 0000000..8c2e94c
+use crate::ast::*;
+use crate::parser::parse_markdown;
+
+#[test]
+fn code_block_indented1() {
+ let doc = parse_markdown(" a").unwrap();
+ assert_eq!(
+ doc,
+ Document {
+ blocks: vec![Block::CodeBlock(CodeBlock {
+ kind: CodeBlockKind::Indented,
+ literal: " a".to_owned()
+ })]
+ }
+ );
+}
+
+#[test]
+fn code_block_indented2() {
+ let doc = parse_markdown(" a\n b").unwrap();
+ assert_eq!(
+ doc,
+ Document {
+ blocks: vec![Block::CodeBlock(CodeBlock {
+ kind: CodeBlockKind::Indented,
+ literal: " a\nb".to_owned()
+ })]
+ }
+ );
+}
+
+#[test]
+fn code_block_fenced1() {
+ let doc = parse_markdown("```\na\n```").unwrap();
+ assert_eq!(
+ doc,
+ Document {
+ blocks: vec![Block::CodeBlock(CodeBlock {
+ kind: CodeBlockKind::Fenced { info: None },
+ literal: "a".to_owned()
+ })]
+ }
+ );
+}
+
+#[test]
+fn code_block_fenced2() {
+ let doc = parse_markdown(" `````\na\n`````````").unwrap();
+ assert_eq!(
+ doc,
+ Document {
+ blocks: vec![Block::CodeBlock(CodeBlock {
+ kind: CodeBlockKind::Fenced { info: None },
+ literal: "a".to_owned()
+ })]
+ }
+ );
+}
+
+#[test]
+fn code_block_fenced3() {
+ let doc = parse_markdown(" ```\n a\n b\n```").unwrap();
+ assert_eq!(
+ doc,
+ Document {
+ blocks: vec![Block::CodeBlock(CodeBlock {
+ kind: CodeBlockKind::Fenced { info: None },
+ literal: " a\n b".to_owned()
+ })]
+ }
+ );
+}
+
+#[test]
+fn code_block_fenced4() {
+ let doc = parse_markdown(" ```rust\na\n```").unwrap();
+ assert_eq!(
+ doc,
+ Document {
+ blocks: vec![Block::CodeBlock(CodeBlock {
+ kind: CodeBlockKind::Fenced {
+ info: Some("rust".to_owned())
+ },
+ literal: "a".to_owned()
+ })]
+ }
+ );
+}
diff --git a/sable-markdown/src/parser/blocks/tests/custom_parser.rs b/sable-markdown/src/parser/blocks/tests/custom_parser.rs
new file mode 100644
index 0000000..8bd3ce7
+use crate::ast::*;
+use crate::parser::{parse_markdown, MarkdownParserState};
+use nom::combinator::value;
+use std::cell::RefCell;
+use std::rc::Rc;
+
+#[test]
+fn custom_parser1() {
+ use nom::Parser;
+ let config = crate::parser::config::MarkdownParserConfig::default().with_custom_block_parser(
+ Rc::new(RefCell::new(Box::new(|input: &str| {
+ value(vec![Block::ThematicBreak], nom::bytes::complete::tag("///")).parse(input)
+ }))),
+ );
+ let doc = parse_markdown(MarkdownParserState::with_config(config), "///\ntext\n===").unwrap();
+ assert_eq!(
+ doc,
+ Document {
+ blocks: vec![
+ Block::ThematicBreak,
+ Block::Heading(Heading {
+ kind: HeadingKind::Setext(SetextHeading::Level1),
+ content: vec![Inline::Text("text".to_owned())]
+ })
+ ]
+ }
+ );
+}
diff --git a/sable-markdown/src/parser/blocks/tests/footnote_definition.rs b/sable-markdown/src/parser/blocks/tests/footnote_definition.rs
new file mode 100644
index 0000000..6bf0cf7
+use crate::ast::*;
+use crate::parser::parse_markdown;
+
+#[test]
+fn footnote_definition1() {
+ let doc = parse_markdown("[^foo]: definition").unwrap();
+ assert_eq!(
+ doc,
+ Document {
+ blocks: vec![Block::FootnoteDefinition(FootnoteDefinition {
+ label: "foo".to_owned(),
+ blocks: vec![Block::Paragraph(vec![Inline::Text(
+ "definition".to_owned()
+ )])]
+ })]
+ }
+ );
+}
+
+#[test]
+fn footnote_definition2() {
+ let doc = parse_markdown(
+ "[^foo]: line1
+ line2
+",
+ )
+ .unwrap();
+ assert_eq!(
+ doc,
+ Document {
+ blocks: vec![Block::FootnoteDefinition(FootnoteDefinition {
+ label: "foo".to_owned(),
+ blocks: vec![Block::Paragraph(vec![Inline::Text(
+ "line1\nline2".to_owned()
+ ),])]
+ })]
+ }
+ );
+}
diff --git a/sable-markdown/src/parser/blocks/tests/heading.rs b/sable-markdown/src/parser/blocks/tests/heading.rs
new file mode 100644
index 0000000..b467e36
+use crate::{ast::*, parser::parse_markdown};
+
+#[test]
+fn heading_v1() {
+ let doc = parse_markdown("## a").unwrap();
+ assert_eq!(
+ doc,
+ Document {
+ blocks: vec![Block::Heading(Heading {
+ kind: HeadingKind::Atx(2),
+ content: vec![Inline::Text("a".to_owned())]
+ })]
+ }
+ );
+}
+
+#[test]
+#[should_panic]
+fn heading_v1_no_space() {
+ let doc = parse_markdown("##a").unwrap();
+ assert_eq!(
+ doc,
+ Document {
+ blocks: vec![Block::Heading(Heading {
+ kind: HeadingKind::Atx(2),
+ content: vec![Inline::Text("a".to_owned())]
+ })]
+ }
+ );
+}
+
+#[test]
+fn heading_v2() {
+ let doc = parse_markdown("a\n==").unwrap();
+ assert_eq!(
+ doc,
+ Document {
+ blocks: vec![Block::Heading(Heading {
+ kind: HeadingKind::Setext(SetextHeading::Level1),
+ content: vec![Inline::Text("a".to_owned())]
+ })]
+ }
+ );
+
+ let doc = parse_markdown("a\n--").unwrap();
+ assert_eq!(
+ doc,
+ Document {
+ blocks: vec![Block::Heading(Heading {
+ kind: HeadingKind::Setext(SetextHeading::Level2),
+ content: vec![Inline::Text("a".to_owned())]
+ })]
+ }
+ );
+}
diff --git a/sable-markdown/src/parser/blocks/tests/html_block.rs b/sable-markdown/src/parser/blocks/tests/html_block.rs
new file mode 100644
index 0000000..fa77343
+use crate::{ast::*, parser::parse_markdown};
+
+#[test]
+fn html_block1() {
+ let doc = parse_markdown("<script>\n</script>").unwrap();
+ assert_eq!(
+ doc,
+ Document {
+ blocks: vec![Block::HtmlBlock("<script>\n</script>".to_owned())]
+ }
+ );
+
+ let doc = parse_markdown("<script>\n\n<h1>hello</h1></script>").unwrap();
+ assert_eq!(
+ doc,
+ Document {
+ blocks: vec![Block::HtmlBlock(
+ "<script>\n\n<h1>hello</h1></script>".to_owned()
+ )]
+ }
+ );
+}
+
+#[test]
+fn html_block2() {
+ let doc = parse_markdown("<!-- \n\nsome commented\n out code -->").unwrap();
+ assert_eq!(
+ doc,
+ Document {
+ blocks: vec![Block::HtmlBlock(
+ "<!-- \n\nsome commented\n out code -->".to_owned()
+ )]
+ }
+ );
+}
+
+#[test]
+fn html_block3() {
+ let doc = parse_markdown(" <? \n\nsome \n code ?> ").unwrap();
+ assert_eq!(
+ doc,
+ Document {
+ blocks: vec![Block::HtmlBlock("<? \n\nsome \n code ?>".to_owned())]
+ }
+ );
+}
+
+#[test]
+fn html_block4() {
+ let doc = parse_markdown(" <!A some \n\n\n text > ").unwrap();
+ assert_eq!(
+ doc,
+ Document {
+ blocks: vec![Block::HtmlBlock("<!A some \n\n\n text >".to_owned())]
+ }
+ );
+}
+
+#[test]
+fn html_block5() {
+ let doc = parse_markdown(" <![CDATA[ ]\n\n[[]]<> ]]> ").unwrap();
+ assert_eq!(
+ doc,
+ Document {
+ blocks: vec![Block::HtmlBlock("<![CDATA[ ]\n\n[[]]<> ]]>".to_owned())]
+ }
+ );
+}
+
+#[test]
+fn html_block6() {
+ let doc = parse_markdown(" <body \n\n").unwrap();
+ assert_eq!(
+ doc,
+ Document {
+ blocks: vec![Block::HtmlBlock("<body \n".to_owned())]
+ }
+ );
+
+ let doc = parse_markdown(" <body a b=c d='e' f=\"g\" >\n</body>\n\n").unwrap();
+ assert_eq!(
+ doc,
+ Document {
+ blocks: vec![Block::HtmlBlock(
+ "<body a b=c d='e' f=\"g\" >\n</body>\n".to_owned()
+ )]
+ }
+ );
+
+ let doc = parse_markdown(" </body> <p>\n</p>\n\n").unwrap();
+ assert_eq!(
+ doc,
+ Document {
+ blocks: vec![Block::HtmlBlock("</body> <p>\n</p>\n".to_owned())]
+ }
+ );
+}
+
+// #[test]
+// fn html_block_skip1() {
+// let config = crate::parser::config::MarkdownParserConfig::default()
+// .with_block_html_block_behavior(crate::parser::config::ElementBehavior::Skip);
+// let doc = parse_markdown(
+// MarkdownParserState::with_config(config.clone()),
+// "<script>\n</script>",
+// )
+// .unwrap();
+// assert_eq!(
+// doc,
+// Document {
+// blocks: vec![Block::Empty]
+// }
+// );
+
+// let doc = parse_markdown(
+// MarkdownParserState::with_config(config),
+// "<script>\n\n<h1>hello</h1></script>",
+// )
+// .unwrap();
+// assert_eq!(
+// doc,
+// Document {
+// blocks: vec![Block::Empty]
+// }
+// );
+// }
+
+// #[test]
+// fn html_block_ignore1() {
+// let config = crate::parser::config::MarkdownParserConfig::default()
+// .with_block_html_block_behavior(crate::parser::config::ElementBehavior::Ignore);
+// let doc = parse_markdown(
+// MarkdownParserState::with_config(config.clone()),
+// "<script>\n</script>",
+// )
+// .unwrap();
+// assert_eq!(
+// doc,
+// Document {
+// blocks: vec![Block::Paragraph(vec![Inline::Text(
+// "<script>\n</script>".to_owned()
+// )])]
+// }
+// );
+
+// let doc = parse_markdown(
+// MarkdownParserState::with_config(config),
+// "<script>\n\n<h1>hello</h1></script>",
+// )
+// .unwrap();
+// assert_eq!(
+// doc,
+// Document {
+// blocks: vec![
+// Block::Paragraph(vec![Inline::Text("<script>".to_owned())]),
+// Block::Paragraph(vec![Inline::Text("<h1>hello</h1></script>".to_owned())])
+// ]
+// }
+// );
+// }
diff --git a/sable-markdown/src/parser/blocks/tests/link_definition.rs b/sable-markdown/src/parser/blocks/tests/link_definition.rs
new file mode 100644
index 0000000..ea96a42
+use crate::{ast::*, parser::parse_markdown};
+
+#[test]
+fn link_definition1() {
+ let doc = parse_markdown("[foo]: /url \"title\"").unwrap();
+ assert_eq!(
+ doc,
+ Document {
+ blocks: vec![Block::Definition(LinkDefinition {
+ label: vec![Inline::Text("foo".to_owned())],
+ destination: "/url".to_owned(),
+ title: Some("title".to_owned())
+ })]
+ }
+ );
+}
+
+#[test]
+fn link_definition2() {
+ let doc = parse_markdown(
+ " [foo]:
+ /url
+ 'the title'
+",
+ )
+ .unwrap();
+ assert_eq!(
+ doc,
+ Document {
+ blocks: vec![Block::Definition(LinkDefinition {
+ label: vec![Inline::Text("foo".to_owned())],
+ destination: "/url".to_owned(),
+ title: Some("the title".to_owned())
+ })]
+ }
+ );
+}
+
+#[test]
+fn link_definition3() {
+ let doc = parse_markdown("[Foo*bar\\]]:my_(url) 'title (with parens)'").unwrap();
+ assert_eq!(
+ doc,
+ Document {
+ blocks: vec![Block::Definition(LinkDefinition {
+ label: vec![Inline::Text("Foo*bar]".to_owned())],
+ destination: "my_(url)".to_owned(),
+ title: Some("title (with parens)".to_owned())
+ })]
+ }
+ );
+}
+
+#[test]
+fn link_definition4() {
+ let doc = parse_markdown(
+ "[Foo bar]:
+<my url>
+'title'",
+ )
+ .unwrap();
+ assert_eq!(
+ doc,
+ Document {
+ blocks: vec![Block::Definition(LinkDefinition {
+ label: vec![Inline::Text("Foo bar".to_owned())],
+ destination: "my url".to_owned(),
+ title: Some("title".to_owned())
+ })]
+ }
+ );
+}
+
+#[test]
+fn link_definition5() {
+ let doc = parse_markdown(
+ "[foo]: /url '
+title
+line1
+line2
+'",
+ )
+ .unwrap();
+ assert_eq!(
+ doc,
+ Document {
+ blocks: vec![Block::Definition(LinkDefinition {
+ label: vec![Inline::Text("foo".to_owned())],
+ destination: "/url".to_owned(),
+ title: Some("\ntitle\nline1\nline2\n".to_owned())
+ })]
+ }
+ );
+}
+
+#[test]
+fn link_definition6() {
+ let doc = parse_markdown(
+ "[foo]:
+/url",
+ )
+ .unwrap();
+ assert_eq!(
+ doc,
+ Document {
+ blocks: vec![Block::Definition(LinkDefinition {
+ label: vec![Inline::Text("foo".to_owned())],
+ destination: "/url".to_owned(),
+ title: None
+ })]
+ }
+ );
+}
+
+#[test]
+fn link_definition7() {
+ let doc = parse_markdown("[foo]: <>").unwrap();
+ assert_eq!(
+ doc,
+ Document {
+ blocks: vec![Block::Definition(LinkDefinition {
+ label: vec![Inline::Text("foo".to_owned())],
+ destination: "".to_owned(),
+ title: None
+ })]
+ }
+ );
+}
+
+// #[test]
+// fn link_definition_mapped1() {
+// let config = MarkdownParserConfig::default().with_block_link_definition_behavior(
+// ElementBehavior::Map(Rc::new(RefCell::new(Box::new(|block| {
+// if let Block::Definition(v) = block {
+// let mut label = vec![Inline::Text("mapped ".to_owned())];
+// label.extend(v.label);
+// Block::Definition(LinkDefinition {
+// label,
+// destination: format!("mapped {}", v.destination),
+// title: v.title.map(|t| format!("mapped {t}")),
+// })
+// } else {
+// block
+// }
+// })))),
+// );
+// let doc = parse_markdown(
+// MarkdownParserState::with_config(config),
+// "[foo]: /url \"title\"",
+// )
+// .unwrap();
+// assert_eq!(
+// doc,
+// Document {
+// blocks: vec![Block::Definition(LinkDefinition {
+// label: vec![
+// Inline::Text("mapped ".to_owned()),
+// Inline::Text("foo".to_owned())
+// ],
+// destination: "mapped /url".to_owned(),
+// title: Some("mapped title".to_owned())
+// })]
+// }
+// );
+// }
+
+// #[test]
+// fn link_definition_mapped2() {
+// let config = MarkdownParserConfig::default().with_block_link_definition_behavior(
+// ElementBehavior::FlatMap(Rc::new(RefCell::new(Box::new(|block| {
+// if let Block::Definition(v) = block {
+// let mut label = vec![Inline::Text("mapped ".to_owned())];
+// label.extend(v.label);
+// let link1 = Block::Definition(LinkDefinition {
+// label: label.clone(),
+// destination: format!("mapped {}", v.destination),
+// title: v.title.as_ref().map(|t| format!("mapped1 {t}")),
+// });
+// let link2 = Block::Definition(LinkDefinition {
+// label,
+// destination: format!("mapped {}", v.destination),
+// title: v.title.map(|t| format!("mapped2 {t}")),
+// });
+// vec![link1, link2]
+// } else {
+// vec![block]
+// }
+// })))),
+// );
+// let doc = parse_markdown(
+// MarkdownParserState::with_config(config),
+// "[foo]: /url \"title\"",
+// )
+// .unwrap();
+// assert_eq!(
+// doc,
+// Document {
+// blocks: vec![
+// Block::Definition(LinkDefinition {
+// label: vec![
+// Inline::Text("mapped ".to_owned()),
+// Inline::Text("foo".to_owned())
+// ],
+// destination: "mapped /url".to_owned(),
+// title: Some("mapped1 title".to_owned())
+// }),
+// Block::Definition(LinkDefinition {
+// label: vec![
+// Inline::Text("mapped ".to_owned()),
+// Inline::Text("foo".to_owned())
+// ],
+// destination: "mapped /url".to_owned(),
+// title: Some("mapped2 title".to_owned())
+// }),
+// ]
+// }
+// );
+// }
diff --git a/sable-markdown/src/parser/blocks/tests/list.rs b/sable-markdown/src/parser/blocks/tests/list.rs
new file mode 100644
index 0000000..4f9e118
+use crate::{ast::*, parser::parse_markdown};
+
+#[test]
+fn list1() {
+ let doc = parse_markdown("1. a").unwrap();
+ assert_eq!(
+ doc,
+ Document {
+ blocks: vec![Block::List(List {
+ kind: ListKind::Ordered(ListOrderedKindOptions { start: 1 }),
+ items: vec![ListItem {
+ task: None,
+ blocks: vec![Block::Paragraph(vec![Inline::Text("a".to_owned())])]
+ }]
+ })]
+ }
+ );
+}
+
+#[test]
+fn list2() {
+ let doc = parse_markdown("0100. a").unwrap();
+ assert_eq!(
+ doc,
+ Document {
+ blocks: vec![Block::List(List {
+ kind: ListKind::Ordered(ListOrderedKindOptions { start: 100 }),
+ items: vec![ListItem {
+ task: None,
+ blocks: vec![Block::Paragraph(vec![Inline::Text("a".to_owned())])]
+ }]
+ })]
+ }
+ );
+}
+
+#[test]
+fn list3() {
+ let doc = parse_markdown("1) a").unwrap();
+ assert_eq!(
+ doc,
+ Document {
+ blocks: vec![Block::List(List {
+ kind: ListKind::Ordered(ListOrderedKindOptions { start: 1 }),
+ items: vec![ListItem {
+ task: None,
+ blocks: vec![Block::Paragraph(vec![Inline::Text("a".to_owned())])]
+ }]
+ })]
+ }
+ );
+}
+
+#[test]
+fn list4() {
+ let doc = parse_markdown(" - a").unwrap();
+ assert_eq!(
+ doc,
+ Document {
+ blocks: vec![Block::List(List {
+ kind: ListKind::Bullet(ListBulletKind::Dash),
+ items: vec![ListItem {
+ task: None,
+ blocks: vec![Block::Paragraph(vec![Inline::Text("a".to_owned())])]
+ }]
+ })]
+ }
+ );
+}
+
+#[test]
+fn list5() {
+ let doc = parse_markdown("1. a\n2. b").unwrap();
+ assert_eq!(
+ doc,
+ Document {
+ blocks: vec![Block::List(List {
+ kind: ListKind::Ordered(ListOrderedKindOptions { start: 1 }),
+ items: vec![
+ ListItem {
+ task: None,
+ blocks: vec![Block::Paragraph(vec![Inline::Text("a".to_owned())])]
+ },
+ ListItem {
+ task: None,
+ blocks: vec![Block::Paragraph(vec![Inline::Text("b".to_owned())])]
+ }
+ ]
+ })]
+ }
+ );
+}
+
+#[test]
+fn list6() {
+ let doc = parse_markdown(" - a\nb").unwrap();
+ assert_eq!(
+ doc,
+ Document {
+ blocks: vec![Block::List(List {
+ kind: ListKind::Bullet(ListBulletKind::Dash),
+ items: vec![ListItem {
+ task: None,
+ blocks: vec![Block::Paragraph(vec![Inline::Text("a\nb".to_owned())])]
+ }]
+ })]
+ }
+ );
+}
+
+#[test]
+fn list7() {
+ let doc = parse_markdown(" - a\nb\n\nc").unwrap();
+ assert_eq!(
+ doc,
+ Document {
+ blocks: vec![
+ Block::List(List {
+ kind: ListKind::Bullet(ListBulletKind::Dash),
+ items: vec![ListItem {
+ task: None,
+ blocks: vec![Block::Paragraph(vec![Inline::Text("a\nb".to_owned())])]
+ }]
+ }),
+ Block::Paragraph(vec![Inline::Text("c".to_owned())])
+ ]
+ },
+ );
+}
+
+#[test]
+fn list8() {
+ let doc = parse_markdown(" - a\nb\n\n c").unwrap();
+ assert_eq!(
+ doc,
+ Document {
+ blocks: vec![Block::List(List {
+ kind: ListKind::Bullet(ListBulletKind::Dash),
+ items: vec![ListItem {
+ task: None,
+ blocks: vec![
+ Block::Paragraph(vec![Inline::Text("a\nb".to_owned())]),
+ Block::Paragraph(vec![Inline::Text("c".to_owned())]),
+ ]
+ }]
+ })]
+ },
+ );
+}
+
+#[test]
+fn list9() {
+ let doc = parse_markdown("1. list1\n * list2\n * list2\n2. list1").unwrap();
+ assert_eq!(
+ doc,
+ Document {
+ blocks: vec![Block::List(List {
+ kind: ListKind::Ordered(ListOrderedKindOptions { start: 1 }),
+ items: vec![
+ ListItem {
+ task: None,
+ blocks: vec![
+ Block::Paragraph(vec![Inline::Text("list1".to_owned())]),
+ Block::List(List {
+ kind: ListKind::Bullet(ListBulletKind::Star),
+ items: vec![
+ ListItem {
+ task: None,
+ blocks: vec![Block::Paragraph(vec![Inline::Text(
+ "list2".to_owned()
+ )]),]
+ },
+ ListItem {
+ task: None,
+ blocks: vec![Block::Paragraph(vec![Inline::Text(
+ "list2".to_owned()
+ )]),]
+ }
+ ]
+ })
+ ]
+ },
+ ListItem {
+ task: None,
+ blocks: vec![Block::Paragraph(vec![Inline::Text("list1".to_owned())])]
+ }
+ ]
+ })]
+ },
+ );
+}
+
+#[test]
+fn list10() {
+ let doc = parse_markdown(" * list1\n * list1").unwrap();
+ assert_eq!(
+ doc,
+ Document {
+ blocks: vec![Block::List(List {
+ kind: ListKind::Bullet(ListBulletKind::Star),
+ items: vec![
+ ListItem {
+ task: None,
+ blocks: vec![Block::Paragraph(vec![Inline::Text("list1".to_owned())])]
+ },
+ ListItem {
+ task: None,
+ blocks: vec![Block::Paragraph(vec![Inline::Text("list1".to_owned())])]
+ }
+ ]
+ })]
+ },
+ );
+}
+
+#[test]
+fn task_list1() {
+ let doc = parse_markdown(" - [ ] a").unwrap();
+ assert_eq!(
+ doc,
+ Document {
+ blocks: vec![Block::List(List {
+ kind: ListKind::Bullet(ListBulletKind::Dash),
+ items: vec![ListItem {
+ task: Some(TaskState::Incomplete),
+ blocks: vec![Block::Paragraph(vec![Inline::Text("a".to_owned())])]
+ }]
+ })]
+ },
+ );
+}
+
+#[test]
+fn task_list2() {
+ let doc = parse_markdown(" - [x] a").unwrap();
+ assert_eq!(
+ doc,
+ Document {
+ blocks: vec![Block::List(List {
+ kind: ListKind::Bullet(ListBulletKind::Dash),
+ items: vec![ListItem {
+ task: Some(TaskState::Complete),
+ blocks: vec![Block::Paragraph(vec![Inline::Text("a".to_owned())])]
+ }]
+ })]
+ },
+ );
+}
+
+#[test]
+fn task_list3() {
+ let doc = parse_markdown(" - [x] a").unwrap();
+ assert_eq!(
+ doc,
+ Document {
+ blocks: vec![Block::List(List {
+ kind: ListKind::Bullet(ListBulletKind::Dash),
+ items: vec![ListItem {
+ task: Some(TaskState::Complete),
+ blocks: vec![Block::Paragraph(vec![Inline::Text("a".to_owned())])]
+ }]
+ })]
+ },
+ );
+}
+
+#[test]
+fn task_list4() {
+ let doc = parse_markdown(" - [ ] ").unwrap();
+ assert_eq!(
+ doc,
+ Document {
+ blocks: vec![Block::List(List {
+ kind: ListKind::Bullet(ListBulletKind::Dash),
+ items: vec![ListItem {
+ task: Some(TaskState::Incomplete),
+ blocks: vec![]
+ }]
+ })]
+ },
+ );
+}
+
+#[test]
+fn task_list5() {
+ let doc = parse_markdown(" - [ ] \n\n a").unwrap();
+ assert_eq!(
+ doc,
+ Document {
+ blocks: vec![Block::List(List {
+ kind: ListKind::Bullet(ListBulletKind::Dash),
+ items: vec![ListItem {
+ task: Some(TaskState::Incomplete),
+ blocks: vec![Block::Paragraph(vec![Inline::Text("a".to_owned())])]
+ }]
+ })]
+ },
+ );
+}
diff --git a/sable-markdown/src/parser/blocks/tests/mod.rs b/sable-markdown/src/parser/blocks/tests/mod.rs
new file mode 100644
index 0000000..bebe192
+mod blockquote;
+mod callout;
+mod code_block;
+// mod custom_parser;
+mod footnote_definition;
+mod heading;
+mod html_block;
+mod link_definition;
+mod list;
+mod paragraph;
+mod table;
+mod thematic_break;
diff --git a/sable-markdown/src/parser/blocks/tests/paragraph.rs b/sable-markdown/src/parser/blocks/tests/paragraph.rs
new file mode 100644
index 0000000..69340e7
+use crate::{ast::*, parser::parse_markdown};
+
+#[test]
+fn minimal_paragraph() {
+ let doc = parse_markdown("a").unwrap();
+ assert_eq!(
+ doc,
+ Document {
+ blocks: vec![Block::Paragraph(vec![Inline::Text("a".to_string())])]
+ }
+ );
+
+ let doc = parse_markdown("a b c").unwrap();
+ assert_eq!(
+ doc,
+ Document {
+ blocks: vec![Block::Paragraph(vec![Inline::Text("a b c".to_string())])]
+ }
+ );
+
+ let doc = parse_markdown("a\nb\nc").unwrap();
+ assert_eq!(
+ doc,
+ Document {
+ blocks: vec![Block::Paragraph(vec![Inline::Text("a\nb\nc".to_string())])]
+ }
+ );
+}
+
+#[test]
+fn multi_paragraph1() {
+ let doc = parse_markdown("a\n\nb").unwrap();
+ assert_eq!(
+ doc,
+ Document {
+ blocks: vec![
+ Block::Paragraph(vec![Inline::Text("a".to_string())]),
+ Block::Paragraph(vec![Inline::Text("b".to_string())]),
+ ]
+ }
+ );
+}
+
+#[test]
+fn multi_paragraph2() {
+ let doc = parse_markdown("a\n\n\n\n\nb").unwrap();
+ assert_eq!(
+ doc,
+ Document {
+ blocks: vec![
+ Block::Paragraph(vec![Inline::Text("a".to_string())]),
+ Block::Paragraph(vec![Inline::Text("b".to_string())]),
+ ]
+ }
+ );
+}
+
+#[test]
+fn multi_paragraph3() {
+ let doc = parse_markdown("a\n\n b").unwrap();
+ assert_eq!(
+ doc,
+ Document {
+ blocks: vec![
+ Block::Paragraph(vec![Inline::Text("a".to_string())]),
+ Block::Paragraph(vec![Inline::Text("b".to_string())]),
+ ]
+ }
+ );
+}
+
+#[test]
+fn multi_paragraph4() {
+ let doc = parse_markdown("aaa\n\n===").unwrap();
+ assert_eq!(
+ doc,
+ Document {
+ blocks: vec![
+ Block::Paragraph(vec![Inline::Text("aaa".to_string())]),
+ Block::Paragraph(vec![Inline::Text("===".to_string())]),
+ ]
+ }
+ );
+}
+
+#[test]
+fn paragraph_with_indented_line1() {
+ let doc = parse_markdown("a\n b").unwrap();
+ assert_eq!(
+ doc,
+ Document {
+ blocks: vec![Block::Paragraph(vec![Inline::Text("a\n b".to_string())])],
+ }
+ );
+}
diff --git a/sable-markdown/src/parser/blocks/tests/table.rs b/sable-markdown/src/parser/blocks/tests/table.rs
new file mode 100644
index 0000000..40c7ba1
+use crate::{ast::*, parser::parse_markdown};
+
+#[test]
+fn table1() {
+ let doc = parse_markdown(
+ "| foo | bar |
+| --- | --- |
+| baz | bim |",
+ )
+ .unwrap();
+ assert_eq!(
+ doc,
+ Document {
+ blocks: vec![Block::Table(Table {
+ rows: vec![
+ vec![
+ vec![Inline::Text("foo".to_owned())],
+ vec![Inline::Text("bar".to_owned())]
+ ],
+ vec![
+ vec![Inline::Text("baz".to_owned())],
+ vec![Inline::Text("bim".to_owned())]
+ ]
+ ],
+ alignments: vec![Alignment::None, Alignment::None]
+ })]
+ }
+ );
+}
+
+#[test]
+fn table2() {
+ let doc = parse_markdown(
+ "| foo | bar |
+| :-- | --: |
+| baz | bim |",
+ )
+ .unwrap();
+ assert_eq!(
+ doc,
+ Document {
+ blocks: vec![Block::Table(Table {
+ rows: vec![
+ vec![
+ vec![Inline::Text("foo".to_owned())],
+ vec![Inline::Text("bar".to_owned())]
+ ],
+ vec![
+ vec![Inline::Text("baz".to_owned())],
+ vec![Inline::Text("bim".to_owned())]
+ ]
+ ],
+ alignments: vec![Alignment::Left, Alignment::Right]
+ })]
+ }
+ );
+}
+
+#[test]
+fn table3() {
+ let doc = parse_markdown(
+ "| foo | bar |
+| --- | --- |
+| baz | b\\|im |",
+ )
+ .unwrap();
+ assert_eq!(
+ doc,
+ Document {
+ blocks: vec![Block::Table(Table {
+ rows: vec![
+ vec![
+ vec![Inline::Text("foo".to_owned())],
+ vec![Inline::Text("bar".to_owned())]
+ ],
+ vec![
+ vec![Inline::Text("baz".to_owned())],
+ vec![Inline::Text("b|im".to_owned())]
+ ]
+ ],
+ alignments: vec![Alignment::None, Alignment::None]
+ })]
+ }
+ );
+}
+
+#[test]
+fn table4() {
+ let doc = parse_markdown(
+ "| abc | def |
+| --- | --- |
+| bar |
+| bar | baz | boo |",
+ )
+ .unwrap();
+ assert_eq!(
+ doc,
+ Document {
+ blocks: vec![Block::Table(Table {
+ rows: vec![
+ vec![
+ vec![Inline::Text("abc".to_owned())],
+ vec![Inline::Text("def".to_owned())]
+ ],
+ vec![
+ vec![Inline::Text("bar".to_owned())],
+ vec![Inline::Text(String::new())],
+ ],
+ vec![
+ vec![Inline::Text("bar".to_owned())],
+ vec![Inline::Text("baz".to_owned())],
+ ]
+ ],
+ alignments: vec![Alignment::None, Alignment::None]
+ })]
+ }
+ );
+}
diff --git a/sable-markdown/src/parser/blocks/tests/thematic_break.rs b/sable-markdown/src/parser/blocks/tests/thematic_break.rs
new file mode 100644
index 0000000..3e65ac6
+use crate::{ast::*, parser::parse_markdown};
+
+#[test]
+fn thematic_break() {
+ let doc = parse_markdown("---").unwrap();
+ assert_eq!(
+ doc,
+ Document {
+ blocks: vec![Block::ThematicBreak]
+ }
+ );
+}
diff --git a/sable-markdown/src/parser/blocks/thematic_break.rs b/sable-markdown/src/parser/blocks/thematic_break.rs
new file mode 100644
index 0000000..b131781
+use nom::{
+ IResult, Parser,
+ branch::alt,
+ character::complete::{char, space0},
+ combinator::map,
+ multi::{many, many_m_n},
+ sequence::{preceded, terminated},
+};
+
+use crate::parser::util::line_terminated;
+
+pub(super) fn thematic_break<'a>() -> impl FnMut(&'a str) -> IResult<&'a str, ()> {
+ move |input: &str| {
+ map(
+ line_terminated(preceded(
+ many_m_n(0, 3, char(' ')),
+ terminated(
+ alt((
+ many(3.., char('-')),
+ many(3.., char('_')),
+ many(3.., char('*')),
+ )),
+ space0,
+ ),
+ )),
+ |_: Vec<_>| (),
+ )
+ .parse(input)
+ }
+}
diff --git a/sable-markdown/src/parser/inline/autolink.rs b/sable-markdown/src/parser/inline/autolink.rs
new file mode 100644
index 0000000..a7fea83
+use nom::{
+ IResult, Parser,
+ branch::alt,
+ bytes::complete::{take_while, take_while1},
+ character::complete::{char, satisfy},
+ combinator::{map, recognize},
+ sequence::{delimited, pair, terminated},
+};
+
+pub(super) fn autolink(input: &str) -> IResult<&str, String> {
+ delimited(char('<'), alt((uri, email)), char('>')).parse(input)
+}
+
+/// uri: scheme ":" [^<>\u0000-\u0020]*
+fn uri(input: &str) -> IResult<&str, String> {
+ map(
+ pair(
+ terminated(scheme, char(':')),
+ take_while(|c: char| {
+ !c.is_ascii_control() && !c.is_ascii_whitespace() && c != '<' && c != '>'
+ }),
+ ),
+ |(scheme_part, rest): (&str, &str)| {
+ let mut s = String::from(scheme_part);
+ s.push(':');
+ s.push_str(rest);
+ s
+ },
+ )
+ .parse(input)
+}
+
+/// email: simplified form for <[email protected]>
+fn email(input: &str) -> IResult<&str, String> {
+ map(
+ recognize(pair(
+ take_while1(|c: char| c.is_ascii_alphanumeric() || "_-.".contains(c)),
+ pair(
+ char('@'),
+ take_while1(|c: char| c.is_ascii_alphanumeric() || ".-".contains(c)),
+ ),
+ )),
+ |v: &str| v.to_string(),
+ )
+ .parse(input)
+}
+
+/// scheme: [a-zA-Z][a-zA-Z0-9+.-]{1,31}
+fn scheme(input: &str) -> IResult<&str, &str> {
+ recognize(pair(satisfy(is_scheme_start), take_while1(is_scheme_char))).parse(input)
+}
+
+const fn is_scheme_char(c: char) -> bool {
+ c.is_ascii_alphanumeric() || matches!(c, '+' | '.' | '-')
+}
+
+const fn is_scheme_start(c: char) -> bool {
+ c.is_ascii_alphabetic()
+}
diff --git a/sable-markdown/src/parser/inline/code_span.rs b/sable-markdown/src/parser/inline/code_span.rs
new file mode 100644
index 0000000..838a245
+use nom::{
+ IResult, Parser,
+ branch::alt,
+ bytes::complete::tag,
+ character::complete::{anychar, char, line_ending, space0},
+ combinator::{not, peek, recognize, value},
+ multi::many1,
+ sequence::preceded,
+};
+
+pub(super) fn code_span(input: &str) -> IResult<&str, String> {
+ let (input, open_ticks) = backtick_string(input)?;
+ let tick_count = open_ticks.len();
+ let closing_tag_value = "`".repeat(tick_count);
+
+ let not_a_closing_tag = (tag(closing_tag_value.as_str()), char('`'));
+ let closing_tag = preceded(
+ peek(not(not_a_closing_tag)),
+ tag(closing_tag_value.as_str()),
+ );
+ let empty_line = (line_ending, space0, line_ending);
+ let content_parser = preceded(
+ peek(not(alt((value((), closing_tag), value((), empty_line))))),
+ anychar,
+ );
+
+ let (input, content) = recognize(many1(content_parser)).parse(input)?;
+ let (input, _) = tag(closing_tag_value.as_str()).parse(input)?;
+
+ let mut content = content.replace("\r\n", " ").replace('\n', " ");
+ if content.starts_with(' ') && content.ends_with(' ') && content.trim() != "" {
+ content = content[1..content.len() - 1].to_string();
+ }
+
+ Ok((input, content))
+}
+
+fn backtick_string(input: &str) -> IResult<&str, &str> {
+ recognize(many1(char('`'))).parse(input)
+}
diff --git a/sable-markdown/src/parser/inline/emphasis.rs b/sable-markdown/src/parser/inline/emphasis.rs
new file mode 100644
index 0000000..40f6a91
+use nom::{
+ IResult, Parser,
+ branch::alt,
+ bytes::complete::tag,
+ character::complete::anychar,
+ combinator::{map, map_opt, not, peek, recognize, value, verify},
+ multi::many1,
+ sequence::{delimited, preceded},
+};
+
+use crate::ast::Inline;
+
+pub(super) fn emphasis() -> impl FnMut(&str) -> IResult<&str, Inline> {
+ move |input: &str| {
+ alt((
+ map(
+ alt((
+ delimited(
+ open_tag("***"),
+ emphasis_content(close_tag("***")),
+ close_tag("***"),
+ ),
+ delimited(
+ open_tag("___"),
+ emphasis_content(close_tag("___")),
+ close_tag("___"),
+ ),
+ )),
+ |inner| Inline::Strong(vec![Inline::Emphasis(inner)]),
+ ),
+ map(
+ alt((
+ delimited(
+ open_tag("**"),
+ emphasis_content(close_tag("**")),
+ close_tag("**"),
+ ),
+ delimited(
+ open_tag("__"),
+ emphasis_content(close_tag("__")),
+ close_tag("__"),
+ ),
+ )),
+ Inline::Strong,
+ ),
+ map(
+ alt((
+ delimited(
+ open_tag("*"),
+ emphasis_content(close_tag("*")),
+ close_tag("*"),
+ ),
+ delimited(
+ open_tag("_"),
+ emphasis_content(close_tag("_")),
+ close_tag("_"),
+ ),
+ )),
+ Inline::Emphasis,
+ ),
+ ))
+ .parse(input)
+ }
+}
+
+fn emphasis_content<'a, P>(mut close_tag: P) -> impl FnMut(&'a str) -> IResult<&'a str, Vec<Inline>>
+where
+ P: Parser<&'a str, Output = (), Error = nom::error::Error<&'a str>>,
+{
+ move |input: &str| {
+ let not_end = |i: &'a str| close_tag.parse(i);
+ map_opt(
+ recognize(many1(preceded(
+ peek(not(not_end)),
+ alt((value((), tag("\\*")), value((), anychar))),
+ ))),
+ |content: &str| {
+ crate::parser::inline::inline_many1()
+ .parse(content)
+ .map(|(_, content)| content)
+ .ok()
+ },
+ )
+ .parse(input)
+ }
+}
+
+fn open_tag(tag_value: &'static str) -> impl FnMut(&str) -> IResult<&str, ()> {
+ move |input: &str| {
+ value(
+ (),
+ verify(tag(tag_value), |v: &str| {
+ can_open(v.chars().next().unwrap(), input.chars().nth(v.len()))
+ }),
+ )
+ .parse(input)
+ }
+}
+
+fn can_open(marker: char, next: Option<char>) -> bool {
+ let left_flanking = next.is_some_and(|c| !c.is_whitespace())
+ && (next.is_some_and(|c| !is_punctuation(c)) || (next.is_some_and(is_punctuation)));
+ if !left_flanking {
+ return false;
+ }
+ if marker == '_' {
+ let right_flanking = next.is_none_or(|c| c.is_whitespace() || is_punctuation(c));
+ return !right_flanking;
+ }
+ true
+}
+
+fn close_tag(tag_value: &'static str) -> impl FnMut(&str) -> IResult<&str, ()> {
+ move |input: &str| {
+ value(
+ (),
+ verify(tag(tag_value), |v: &str| {
+ can_close(v.chars().next().unwrap(), input.chars().nth(v.len()))
+ }),
+ )
+ .parse(input)
+ }
+}
+
+fn can_close(marker: char, next: Option<char>) -> bool {
+ let right_flanking = next.is_none_or(|c| c.is_whitespace() || is_punctuation(c));
+ if !right_flanking {
+ return false;
+ }
+
+ if marker == '_' {
+ let left_flanking = next.is_some_and(|c| !c.is_whitespace())
+ && (next.is_some_and(|c| !is_punctuation(c)))
+ || (next.is_some_and(is_punctuation));
+ return !left_flanking || next.is_some_and(is_punctuation);
+ }
+ true
+}
+
+fn is_punctuation(c: char) -> bool {
+ use unicode_categories::UnicodeCategories;
+ c.is_ascii_punctuation() || c.is_punctuation()
+}
diff --git a/sable-markdown/src/parser/inline/file_includes.rs b/sable-markdown/src/parser/inline/file_includes.rs
new file mode 100644
index 0000000..e69de29
diff --git a/sable-markdown/src/parser/inline/footnote_reference.rs b/sable-markdown/src/parser/inline/footnote_reference.rs
new file mode 100644
index 0000000..e978baa
+use nom::{
+ IResult, Parser,
+ bytes::complete::tag,
+ character::complete::{alphanumeric1, char},
+ combinator::map,
+ sequence::delimited,
+};
+
+use crate::ast::Inline;
+
+pub(super) fn footnote_reference<'a>(input: &'a str) -> IResult<&'a str, Inline> {
+ map(
+ delimited(tag("[^"), alphanumeric1, char(']')),
+ |s: &'a str| Inline::FootnoteReference(s.to_owned()),
+ )
+ .parse(input)
+}
diff --git a/sable-markdown/src/parser/inline/hard_newline.rs b/sable-markdown/src/parser/inline/hard_newline.rs
new file mode 100644
index 0000000..62c7aa3
+use nom::{
+ IResult, Parser,
+ branch::alt,
+ character::complete::{char, line_ending},
+ combinator::value,
+ multi::many_m_n,
+ sequence::pair,
+};
+
+use crate::ast::Inline;
+
+pub(super) fn hard_newline(input: &str) -> IResult<&str, Inline> {
+ value(
+ Inline::LineBreak,
+ alt((
+ value((), pair(char('\\'), line_ending)),
+ value((), pair(many_m_n(2, usize::MAX, char(' ')), line_ending)),
+ )),
+ )
+ .parse(input)
+}
diff --git a/sable-markdown/src/parser/inline/html_entity.rs b/sable-markdown/src/parser/inline/html_entity.rs
new file mode 100644
index 0000000..6a64dc9
+use nom::{
+ IResult, Parser,
+ branch::alt,
+ bytes::complete::tag,
+ character::complete::{alpha1, char, digit1, hex_digit1, one_of},
+ combinator::{map, map_opt, recognize},
+ sequence::{delimited, preceded},
+};
+
+use crate::parser::util::ENTITIES;
+
+pub(super) fn html_entity() -> impl FnMut(&str) -> IResult<&str, String> {
+ move |input: &str| alt((html_entity_alpha(), html_entity_numeric)).parse(input)
+}
+
+fn html_entity_alpha() -> impl FnMut(&str) -> IResult<&str, String> {
+ move |input: &str| {
+ map_opt(recognize((char('&'), alpha1, char(';'))), |s: &str| {
+ ENTITIES.get(s).map(|entity| entity.characters.to_owned())
+ })
+ .parse(input)
+ }
+}
+
+fn html_entity_numeric(input: &str) -> IResult<&str, String> {
+ let base16 = map_opt(preceded(one_of("xX"), hex_digit1), |s: &str| {
+ u32::from_str_radix(s, 16).ok()
+ });
+ let base10 = map_opt(digit1, |s: &str| s.parse::<u32>().ok());
+
+ map(
+ map_opt(
+ delimited(tag("&#"), alt((base10, base16)), char(';')),
+ char::from_u32,
+ ),
+ |c: char| c.to_string(),
+ )
+ .parse(input)
+}
diff --git a/sable-markdown/src/parser/inline/image.rs b/sable-markdown/src/parser/inline/image.rs
new file mode 100644
index 0000000..727bc45
+use nom::{
+ IResult, Parser,
+ bytes::complete::take_while1,
+ character::complete::{char, multispace0},
+ combinator::opt,
+ sequence::{delimited, preceded},
+};
+
+use crate::{
+ ast::{Image, Inline},
+ parser::link_util::{link_destination, link_title},
+};
+
+// 
+pub(super) fn image<'a>() -> impl FnMut(&'a str) -> IResult<&'a str, Inline> {
+ move |input: &'a str| {
+ let (input, alt) = preceded(
+ char('!'),
+ delimited(char('['), take_while1(|c| c != ']'), char(']')),
+ )
+ .parse(input)?;
+
+ let (input, (destination, title)) = delimited(
+ char('('),
+ (
+ preceded(multispace0, link_destination),
+ opt(preceded(multispace0, link_title)),
+ ),
+ preceded(multispace0, char(')')),
+ )
+ .parse(input)?;
+
+ Ok((
+ input,
+ Inline::Image(Image {
+ destination,
+ title,
+ alt: alt.to_owned(),
+ }),
+ ))
+ }
+}
diff --git a/sable-markdown/src/parser/inline/inline_link.rs b/sable-markdown/src/parser/inline/inline_link.rs
new file mode 100644
index 0000000..e0aae23
+use nom::{
+ IResult, Parser,
+ character::complete::{char, multispace0},
+ combinator::opt,
+ sequence::{delimited, preceded},
+};
+
+use crate::{
+ ast::Link,
+ parser::link_util::{link_destination, link_label, link_title},
+};
+
+pub(super) fn inline_link<'a>() -> impl FnMut(&'a str) -> IResult<&'a str, Link> {
+ move |input: &'a str| {
+ let (input, (children, (destination, title))) = (
+ link_label(),
+ delimited(
+ char('('),
+ (
+ preceded(multispace0, link_destination),
+ opt(preceded(multispace0, link_title)),
+ ),
+ preceded(multispace0, char(')')),
+ ),
+ )
+ .parse(input)?;
+
+ let link = Link {
+ destination,
+ title,
+ children,
+ };
+
+ Ok((input, link))
+ }
+}
diff --git a/sable-markdown/src/parser/inline/mod.rs b/sable-markdown/src/parser/inline/mod.rs
new file mode 100644
index 0000000..1a4b856
+mod autolink;
+mod code_span;
+mod emphasis;
+mod footnote_reference;
+mod hard_newline;
+mod html_entity;
+mod image;
+mod inline_link;
+mod reference_link;
+mod strikethrough;
+mod tag;
+mod text;
+mod wikilink;
+
+#[cfg(test)]
+mod tests;
+
+use nom::{
+ IResult, Parser,
+ branch::alt,
+ combinator::map,
+ multi::{many0, many1},
+};
+
+use crate::ast::Inline;
+
+pub(super) fn inline_many0<'a>() -> impl FnMut(&'a str) -> IResult<&'a str, Vec<Inline>> {
+ move |input: &'a str| {
+ let (input, list_of_lists) = many0(inline()).parse(input)?;
+ let r: Vec<_> = list_of_lists.into_iter().flatten().collect();
+ Ok((input, r))
+ }
+}
+
+pub(super) fn inline_many1<'a>() -> impl FnMut(&'a str) -> IResult<&'a str, Vec<Inline>> {
+ move |input: &'a str| {
+ let (input, list_of_lists) = many1(inline()).parse(input)?;
+ let r: Vec<_> = list_of_lists.into_iter().flatten().collect();
+ Ok((input, r))
+ }
+}
+
+pub(super) fn inline<'a>() -> impl FnMut(&'a str) -> IResult<&'a str, Vec<Inline>> {
+ move |input: &'a str| {
+ alt((
+ map(wikilink::wikilink(), Inline::Wikilink),
+ map(autolink::autolink, Inline::Autolink),
+ map(inline_link::inline_link(), Inline::Link),
+ footnote_reference::footnote_reference,
+ reference_link::reference_link(),
+ hard_newline::hard_newline,
+ image::image(),
+ map(code_span::code_span, Inline::Code),
+ emphasis::emphasis(),
+ strikethrough::strikethrough(),
+ map(tag::tag(), Inline::Tag),
+ text::text(),
+ ))
+ .map(|v| vec![v])
+ .parse(input)
+ }
+}
diff --git a/sable-markdown/src/parser/inline/reference_link.rs b/sable-markdown/src/parser/inline/reference_link.rs
new file mode 100644
index 0000000..ad8f1db
+use nom::{IResult, Parser, branch::alt, bytes::complete::tag, sequence::terminated};
+
+use crate::{
+ ast::{Inline, LinkReference},
+ parser::link_util::link_label,
+};
+
+pub(super) fn reference_link<'a>() -> impl FnMut(&'a str) -> IResult<&'a str, Inline> {
+ move |input: &'a str| {
+ alt((
+ reference_link_full(),
+ reference_link_collapsed(),
+ reference_link_shortcut(),
+ ))
+ .parse(input)
+ }
+}
+
+pub(super) fn reference_link_full<'a>() -> impl FnMut(&'a str) -> IResult<&'a str, Inline> {
+ move |input: &'a str| {
+ let (input, (text, label)) = (link_label(), link_label()).parse(input)?;
+ let link_reference = LinkReference { label, text };
+ Ok((input, Inline::LinkReference(link_reference)))
+ }
+}
+
+pub(super) fn reference_link_collapsed<'a>() -> impl FnMut(&'a str) -> IResult<&'a str, Inline> {
+ move |input: &'a str| {
+ let (input, text) = terminated(link_label(), tag("[]")).parse(input)?;
+ let link_reference = LinkReference {
+ label: text.clone(),
+ text,
+ };
+ Ok((input, Inline::LinkReference(link_reference)))
+ }
+}
+
+pub(super) fn reference_link_shortcut<'a>() -> impl FnMut(&'a str) -> IResult<&'a str, Inline> {
+ move |input: &'a str| {
+ let (input, text) = link_label().parse(input)?;
+ let link_reference = LinkReference {
+ label: text.clone(),
+ text,
+ };
+ Ok((input, Inline::LinkReference(link_reference)))
+ }
+}
diff --git a/sable-markdown/src/parser/inline/strikethrough.rs b/sable-markdown/src/parser/inline/strikethrough.rs
new file mode 100644
index 0000000..d8788f4
+use nom::{
+ IResult, Parser,
+ branch::alt,
+ bytes::complete::tag,
+ character::complete::{anychar, char},
+ combinator::{not, peek, recognize, value},
+ multi::many1,
+ sequence::{preceded, terminated},
+};
+
+use crate::ast::Inline;
+
+pub(super) fn strikethrough<'a>() -> impl FnMut(&'a str) -> IResult<&'a str, Inline> {
+ move |input: &'a str| {
+ let (input, _) = terminated(tag("~~"), peek(not(char('~')))).parse(input)?;
+ let not_a_closing_tag = (tag("~~"), char('~'));
+ let closing_tag = preceded(peek(not(not_a_closing_tag)), tag("~~"));
+ let content_parser = recognize(many1(preceded(
+ peek(not(closing_tag)),
+ alt((value('~', tag("\\~")), anychar)),
+ )));
+ let (input, content) = recognize(content_parser).parse(input)?;
+ let (input, _) = tag("~~").parse(input)?;
+
+ let (_, inline) = crate::parser::inline::inline_many1().parse(content)?;
+
+ Ok((input, Inline::Strikethrough(inline)))
+ }
+}
diff --git a/sable-markdown/src/parser/inline/tag.rs b/sable-markdown/src/parser/inline/tag.rs
new file mode 100644
index 0000000..4b91d5a
+use nom::{
+ IResult, Parser as _, bytes::complete::take_till1, character::complete::char,
+ sequence::preceded,
+};
+
+use crate::ast::Tag;
+
+pub(super) fn tag<'a>() -> impl FnMut(&'a str) -> IResult<&'a str, Tag> {
+ move |input: &'a str| {
+ let (input, text) =
+ preceded(char('#'), take_till1(|c: char| c.is_whitespace())).parse(input)?;
+
+ let tag = Tag {
+ text: text.to_owned(),
+ };
+
+ Ok((input, tag))
+ }
+}
diff --git a/sable-markdown/src/parser/inline/tests/autolink.rs b/sable-markdown/src/parser/inline/tests/autolink.rs
new file mode 100644
index 0000000..5e4976b
+use crate::{ast::*, parser::parse_markdown};
+
+#[test]
+fn autolink1() {
+ let doc = parse_markdown("<http://foo.bar.baz>").unwrap();
+ assert_eq!(
+ doc,
+ Document {
+ blocks: vec![Block::Paragraph(vec![Inline::Autolink(
+ "http://foo.bar.baz".to_owned()
+ )])]
+ }
+ );
+}
+
+#[test]
+fn autolink2() {
+ let doc = parse_markdown("<irc://foo.bar:2233/baz>").unwrap();
+ assert_eq!(
+ doc,
+ Document {
+ blocks: vec![Block::Paragraph(vec![Inline::Autolink(
+ "irc://foo.bar:2233/baz".to_owned()
+ )])]
+ }
+ );
+}
+
+#[test]
+fn autolink3() {
+ let doc = parse_markdown("<MAILTO:[email protected]>").unwrap();
+ assert_eq!(
+ doc,
+ Document {
+ blocks: vec![Block::Paragraph(vec![Inline::Autolink(
+ "MAILTO:[email protected]".to_owned()
+ )])]
+ }
+ );
+}
+
+#[test]
+fn autolink4() {
+ let doc = parse_markdown("<http://foo.bar/baz bim>").unwrap();
+ assert_eq!(
+ doc,
+ Document {
+ blocks: vec![Block::Paragraph(vec![Inline::Text(
+ "<http://foo.bar/baz bim>".to_owned()
+ )])]
+ }
+ );
+}
+
+#[test]
+fn autolink5() {
+ let doc = parse_markdown("<http://example.com/\\[\\>").unwrap();
+ assert_eq!(
+ doc,
+ Document {
+ blocks: vec![Block::Paragraph(vec![Inline::Autolink(
+ "http://example.com/\\[\\".to_owned()
+ )])]
+ }
+ );
+}
+
+#[test]
+fn autolink6() {
+ let doc = parse_markdown("<>").unwrap();
+ assert_eq!(
+ doc,
+ Document {
+ blocks: vec![Block::Paragraph(vec![Inline::Text("<>".to_owned())])]
+ }
+ );
+}
diff --git a/sable-markdown/src/parser/inline/tests/code_span.rs b/sable-markdown/src/parser/inline/tests/code_span.rs
new file mode 100644
index 0000000..730f6f7
+use crate::{ast::*, parser::parse_markdown};
+
+#[test]
+fn code_span1() {
+ let doc = parse_markdown("`foo`").unwrap();
+ assert_eq!(
+ doc,
+ Document {
+ blocks: vec![Block::Paragraph(vec![Inline::Code("foo".to_string())])],
+ }
+ );
+}
+
+#[test]
+fn code_span2() {
+ let doc = parse_markdown("`` foo ` bar ``").unwrap();
+ assert_eq!(
+ doc,
+ Document {
+ blocks: vec![Block::Paragraph(vec![Inline::Code(
+ "foo ` bar".to_string()
+ )])],
+ }
+ );
+}
+
+#[test]
+fn code_span3() {
+ let doc = parse_markdown(
+ "``
+foo
+bar
+baz
+``",
+ )
+ .unwrap();
+ assert_eq!(
+ doc,
+ Document {
+ blocks: vec![Block::Paragraph(vec![Inline::Code(
+ "foo bar baz".to_string()
+ )])],
+ }
+ );
+}
diff --git a/sable-markdown/src/parser/inline/tests/emphasis.rs b/sable-markdown/src/parser/inline/tests/emphasis.rs
new file mode 100644
index 0000000..6252fd0
+use crate::{ast::*, parser::parse_markdown};
+
+#[test]
+fn emphasis1() {
+ let doc = parse_markdown("*foo bar*").unwrap();
+ assert_eq!(
+ doc,
+ Document {
+ blocks: vec![Block::Paragraph(vec![Inline::Emphasis(vec![
+ Inline::Text("foo bar".to_string())
+ ])])],
+ }
+ );
+}
+
+#[test]
+fn emphasis2() {
+ let doc = parse_markdown("* a *").unwrap();
+ assert_eq!(
+ doc,
+ Document {
+ blocks: vec![Block::List(List {
+ kind: ListKind::Bullet(ListBulletKind::Star),
+ items: vec![ListItem {
+ task: None,
+ blocks: vec![Block::Paragraph(vec![Inline::Text("a *".to_owned())])]
+ }]
+ })]
+ }
+ );
+}
+
+#[test]
+fn emphasis3() {
+ let doc = parse_markdown("foo ___bar___").unwrap();
+ assert_eq!(
+ doc,
+ Document {
+ blocks: vec![Block::Paragraph(vec![
+ Inline::Text("foo ".to_owned()),
+ Inline::Strong(vec![Inline::Emphasis(vec![Inline::Text("bar".to_owned())])])
+ ])]
+ }
+ );
+}
+
+#[test]
+fn emphasis4() {
+ let doc = parse_markdown("**foo ___bar___ baz**").unwrap();
+ assert_eq!(
+ doc,
+ Document {
+ blocks: vec![Block::Paragraph(vec![Inline::Strong(vec![
+ Inline::Text("foo ".to_owned()),
+ Inline::Strong(vec![Inline::Emphasis(vec![Inline::Text("bar".to_owned())])]),
+ Inline::Text(" baz".to_owned())
+ ])])]
+ }
+ );
+}
diff --git a/sable-markdown/src/parser/inline/tests/file_includes.rs b/sable-markdown/src/parser/inline/tests/file_includes.rs
new file mode 100644
index 0000000..e69de29
diff --git a/sable-markdown/src/parser/inline/tests/footnote_reference.rs b/sable-markdown/src/parser/inline/tests/footnote_reference.rs
new file mode 100644
index 0000000..64bab94
+use crate::{ast::*, parser::parse_markdown};
+
+#[test]
+fn footnote_reference1() {
+ let doc = parse_markdown("[^label]").unwrap();
+ assert_eq!(
+ doc,
+ Document {
+ blocks: vec![Block::Paragraph(vec![Inline::FootnoteReference(
+ "label".to_string()
+ )])],
+ }
+ );
+}
diff --git a/sable-markdown/src/parser/inline/tests/hard_newline.rs b/sable-markdown/src/parser/inline/tests/hard_newline.rs
new file mode 100644
index 0000000..d89109e
+use crate::{ast::*, parser::parse_markdown};
+
+#[test]
+fn hard_newline1() {
+ let doc = parse_markdown("line1\\\nline2").unwrap();
+ assert_eq!(
+ doc,
+ Document {
+ blocks: vec![Block::Paragraph(vec![
+ Inline::Text("line1".to_string()),
+ Inline::LineBreak,
+ Inline::Text("line2".to_string())
+ ])],
+ }
+ );
+}
+
+#[test]
+fn hard_newline2() {
+ let doc = parse_markdown("line1 \nline2").unwrap();
+ assert_eq!(
+ doc,
+ Document {
+ blocks: vec![Block::Paragraph(vec![
+ Inline::Text("line1".to_string()),
+ Inline::LineBreak,
+ Inline::Text("line2".to_string())
+ ])],
+ }
+ );
+}
diff --git a/sable-markdown/src/parser/inline/tests/html_entity.rs b/sable-markdown/src/parser/inline/tests/html_entity.rs
new file mode 100644
index 0000000..8a88ff7
+use crate::{ast::*, parser::parse_markdown};
+
+#[test]
+fn html_entity1() {
+ let doc = parse_markdown("&").unwrap();
+ assert_eq!(
+ doc,
+ Document {
+ blocks: vec![Block::Paragraph(vec![Inline::Text("&".to_string())]),]
+ }
+ );
+}
+
+#[test]
+fn html_entity2() {
+ let doc = parse_markdown(" ").unwrap();
+ assert_eq!(
+ doc,
+ Document {
+ blocks: vec![Block::Paragraph(vec![Inline::Text(" ".to_string())]),]
+ }
+ );
+}
+
+#[test]
+fn html_entity3() {
+ let doc = parse_markdown(" ").unwrap();
+ assert_eq!(
+ doc,
+ Document {
+ blocks: vec![Block::Paragraph(vec![Inline::Text(" ".to_string())]),]
+ }
+ );
+}
+
+#[test]
+fn html_entity4() {
+ let doc = parse_markdown("&unknownchar;").unwrap();
+ assert_eq!(
+ doc,
+ Document {
+ blocks: vec![Block::Paragraph(vec![Inline::Text(
+ "&unknownchar;".to_string()
+ )]),]
+ }
+ );
+}
diff --git a/sable-markdown/src/parser/inline/tests/image.rs b/sable-markdown/src/parser/inline/tests/image.rs
new file mode 100644
index 0000000..de95a85
+use crate::{ast::*, parser::parse_markdown};
+
+#[test]
+fn image1() {
+ let doc = parse_markdown("").unwrap();
+ assert_eq!(
+ doc,
+ Document {
+ blocks: vec![Block::Paragraph(vec![Inline::Image(Image {
+ destination: "/url".to_owned(),
+ title: Some("title".to_owned()),
+ alt: "foo".to_owned(),
+ })])]
+ }
+ );
+}
+
+#[test]
+fn image2() {
+ let doc = parse_markdown("").unwrap();
+ assert_eq!(
+ doc,
+ Document {
+ blocks: vec![Block::Paragraph(vec![Inline::Image(Image {
+ destination: "train.jpg".to_owned(),
+ title: None,
+ alt: "foo".to_owned(),
+ })])]
+ }
+ );
+}
+
+#[test]
+fn image3() {
+ let doc = parse_markdown("").unwrap();
+ assert_eq!(
+ doc,
+ Document {
+ blocks: vec![Block::Paragraph(vec![Inline::Image(Image {
+ destination: "url".to_owned(),
+ title: None,
+ alt: "foo".to_owned(),
+ })])]
+ }
+ );
+}
diff --git a/sable-markdown/src/parser/inline/tests/inline_link.rs b/sable-markdown/src/parser/inline/tests/inline_link.rs
new file mode 100644
index 0000000..9f99065
+use crate::{ast::*, parser::parse_markdown};
+
+#[test]
+fn inline_link1() {
+ let doc = parse_markdown("[foo](/url \"title\")").unwrap();
+ assert_eq!(
+ doc,
+ Document {
+ blocks: vec![Block::Paragraph(vec![Inline::Link(Link {
+ destination: "/url".to_owned(),
+ title: Some("title".to_owned()),
+ children: vec![Inline::Text("foo".to_owned())]
+ })])]
+ }
+ );
+}
+
+#[test]
+fn inline_link2() {
+ let doc = parse_markdown("[foo](train.jpg)").unwrap();
+ assert_eq!(
+ doc,
+ Document {
+ blocks: vec![Block::Paragraph(vec![Inline::Link(Link {
+ destination: "train.jpg".to_owned(),
+ title: None,
+ children: vec![Inline::Text("foo".to_owned())]
+ })])]
+ }
+ );
+}
+
+#[test]
+fn inline_link3() {
+ let doc = parse_markdown("[foo](<url>)").unwrap();
+ assert_eq!(
+ doc,
+ Document {
+ blocks: vec![Block::Paragraph(vec![Inline::Link(Link {
+ destination: "url".to_owned(),
+ title: None,
+ children: vec![Inline::Text("foo".to_owned())]
+ })])]
+ }
+ );
+}
diff --git a/sable-markdown/src/parser/inline/tests/mod.rs b/sable-markdown/src/parser/inline/tests/mod.rs
new file mode 100644
index 0000000..d464e87
+mod autolink;
+mod code_span;
+mod emphasis;
+mod footnote_reference;
+mod hard_newline;
+mod html_entity;
+mod image;
+mod inline_link;
+mod reference_link;
+mod strikethrough;
+mod tag;
+mod wikilink;
diff --git a/sable-markdown/src/parser/inline/tests/reference_link.rs b/sable-markdown/src/parser/inline/tests/reference_link.rs
new file mode 100644
index 0000000..4be9dd6
+use crate::{ast::*, parser::parse_markdown};
+
+#[test]
+fn reference_link1() {
+ let doc = parse_markdown("[text][label]").unwrap();
+ assert_eq!(
+ doc,
+ Document {
+ blocks: vec![Block::Paragraph(vec![Inline::LinkReference(
+ LinkReference {
+ label: vec![Inline::Text("label".to_owned())],
+ text: vec![Inline::Text("text".to_owned())],
+ }
+ )])],
+ }
+ );
+}
+
+#[test]
+fn reference_link2() {
+ let doc = parse_markdown("[text][]").unwrap();
+ assert_eq!(
+ doc,
+ Document {
+ blocks: vec![Block::Paragraph(vec![Inline::LinkReference(
+ LinkReference {
+ label: vec![Inline::Text("text".to_owned())],
+ text: vec![Inline::Text("text".to_owned())]
+ }
+ )])],
+ }
+ );
+}
+
+#[test]
+fn reference_link3() {
+ let doc = parse_markdown("[text]").unwrap();
+ assert_eq!(
+ doc,
+ Document {
+ blocks: vec![Block::Paragraph(vec![Inline::LinkReference(
+ LinkReference {
+ label: vec![Inline::Text("text".to_owned())],
+ text: vec![Inline::Text("text".to_owned())]
+ }
+ )])],
+ }
+ );
+}
diff --git a/sable-markdown/src/parser/inline/tests/strikethrough.rs b/sable-markdown/src/parser/inline/tests/strikethrough.rs
new file mode 100644
index 0000000..2e06d74
+use crate::{ast::*, parser::parse_markdown};
+
+#[test]
+fn strikethrough1() {
+ let doc = parse_markdown("~~text~~").unwrap();
+ assert_eq!(
+ doc,
+ Document {
+ blocks: vec![Block::Paragraph(vec![Inline::Strikethrough(vec![
+ Inline::Text("text".to_string())
+ ])])],
+ }
+ );
+}
+
+#[test]
+fn strikethrough2() {
+ let doc = parse_markdown("~~text~~~").unwrap();
+ assert_eq!(
+ doc,
+ Document {
+ blocks: vec![Block::Paragraph(vec![Inline::Strikethrough(vec![
+ Inline::Text("text~".to_string())
+ ])])],
+ }
+ );
+}
+
+#[test]
+fn strikethrough3() {
+ let doc = parse_markdown("~~~text~~~").unwrap();
+ assert_eq!(
+ doc,
+ Document {
+ blocks: vec![Block::Paragraph(vec![
+ Inline::Text("~".to_string()),
+ Inline::Strikethrough(vec![Inline::Text("text~".to_string())])
+ ])],
+ }
+ );
+}
diff --git a/sable-markdown/src/parser/inline/tests/tag.rs b/sable-markdown/src/parser/inline/tests/tag.rs
new file mode 100644
index 0000000..7d9c347
+use crate::{ast::*, parser::parse_markdown};
+
+#[test]
+fn tag() {
+ let doc = parse_markdown("#a").unwrap();
+ assert_eq!(
+ doc,
+ Document {
+ blocks: vec![Block::Paragraph(vec![Inline::Tag(Tag {
+ text: "a".to_owned(),
+ })])]
+ }
+ );
+
+ let doc = parse_markdown("#a/b").unwrap();
+ assert_eq!(
+ doc,
+ Document {
+ blocks: vec![Block::Paragraph(vec![Inline::Tag(Tag {
+ text: "a/b".to_owned(),
+ })])]
+ }
+ );
+}
+
+#[test]
+fn tag2() {
+ let doc = parse_markdown("#a #a/b").unwrap();
+ assert_eq!(
+ doc,
+ Document {
+ blocks: vec![Block::Paragraph(vec![
+ Inline::Tag(Tag {
+ text: "a".to_owned(),
+ }),
+ Inline::Text(" ".to_owned()),
+ Inline::Tag(Tag {
+ text: "a/b".to_owned(),
+ }),
+ ])]
+ }
+ );
+}
diff --git a/sable-markdown/src/parser/inline/tests/wikilink.rs b/sable-markdown/src/parser/inline/tests/wikilink.rs
new file mode 100644
index 0000000..ede225f
+use crate::{ast::*, parser::parse_markdown};
+
+#[test]
+fn wikilink() {
+ let doc = parse_markdown("[[a]]").unwrap();
+ assert_eq!(
+ doc,
+ Document {
+ blocks: vec![Block::Paragraph(vec![Inline::Wikilink(Wikilink {
+ link: "a".to_owned(),
+ target: None,
+ name: None,
+ })])]
+ }
+ );
+
+ let doc = parse_markdown("before [[a]]").unwrap();
+ assert_eq!(
+ doc,
+ Document {
+ blocks: vec![Block::Paragraph(vec![
+ Inline::Text("before ".to_owned()),
+ Inline::Wikilink(Wikilink {
+ link: "a".to_owned(),
+ target: None,
+ name: None,
+ })
+ ])]
+ }
+ );
+}
+
+#[test]
+fn wikilink_targeted() {
+ let doc = parse_markdown("[[a#b]]").unwrap();
+ assert_eq!(
+ doc,
+ Document {
+ blocks: vec![Block::Paragraph(vec![Inline::Wikilink(Wikilink {
+ link: "a".to_owned(),
+ target: Some("b".to_owned()),
+ name: None,
+ })])]
+ }
+ );
+}
+
+#[test]
+fn wikilink_named() {
+ let doc = parse_markdown("[[a|b]]").unwrap();
+ assert_eq!(
+ doc,
+ Document {
+ blocks: vec![Block::Paragraph(vec![Inline::Wikilink(Wikilink {
+ link: "a".to_owned(),
+ target: None,
+ name: Some("b".to_owned()),
+ })])]
+ }
+ );
+}
+
+#[test]
+fn wikilink_targeted_named() {
+ let doc = parse_markdown("[[a#b|c]]").unwrap();
+ assert_eq!(
+ doc,
+ Document {
+ blocks: vec![Block::Paragraph(vec![Inline::Wikilink(Wikilink {
+ link: "a".to_owned(),
+ target: Some("b".to_owned()),
+ name: Some("c".to_owned()),
+ })])]
+ }
+ );
+}
diff --git a/sable-markdown/src/parser/inline/text.rs b/sable-markdown/src/parser/inline/text.rs
new file mode 100644
index 0000000..27e3d7a
+use nom::{
+ IResult, Parser,
+ branch::alt,
+ character::complete::{anychar, char, one_of},
+ combinator::{map, not, peek, recognize, value},
+ multi::many1,
+ sequence::preceded,
+};
+
+use crate::ast::Inline;
+
+pub(super) fn text<'a>() -> impl FnMut(&'a str) -> IResult<&'a str, Inline> {
+ move |input: &'a str| {
+ map(
+ many1(alt((
+ map(escaped_char, |c| c.to_string()),
+ crate::parser::inline::html_entity::html_entity(),
+ map(recognize(many1(preceded(peek(is_text()), anychar))), |c| {
+ c.to_string()
+ }),
+ ))),
+ |vec| Inline::Text(vec.join("")),
+ )
+ .parse(input)
+ }
+}
+
+fn is_text<'a>() -> impl FnMut(&'a str) -> IResult<&'a str, ()> {
+ move |input: &'a str| not(not_a_text()).parse(input)
+}
+
+fn not_a_text<'a>() -> impl FnMut(&'a str) -> IResult<&'a str, Vec<()>> {
+ move |input: &'a str| {
+ alt((
+ value((), crate::parser::inline::wikilink::wikilink()),
+ value((), crate::parser::inline::autolink::autolink),
+ value((), crate::parser::inline::reference_link::reference_link()),
+ value((), crate::parser::inline::hard_newline::hard_newline),
+ value((), crate::parser::inline::html_entity::html_entity()),
+ value((), crate::parser::inline::image::image()),
+ value((), crate::parser::inline::inline_link::inline_link()),
+ value((), crate::parser::inline::code_span::code_span),
+ value((), crate::parser::inline::emphasis::emphasis()),
+ value(
+ (),
+ crate::parser::inline::footnote_reference::footnote_reference,
+ ),
+ value((), crate::parser::inline::strikethrough::strikethrough()),
+ value((), crate::parser::inline::tag::tag()),
+ ))
+ .map(|v| vec![v])
+ .parse(input)
+ }
+}
+
+fn escaped_char(input: &str) -> IResult<&str, char> {
+ preceded(char('\\'), one_of("!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~")).parse(input)
+}
diff --git a/sable-markdown/src/parser/inline/wikilink.rs b/sable-markdown/src/parser/inline/wikilink.rs
new file mode 100644
index 0000000..98af94d
+use nom::{
+ IResult, Parser as _,
+ bytes::complete::{tag, take_till1},
+ character::complete::{char, space0},
+ combinator::opt,
+ sequence::{delimited, preceded},
+};
+
+use crate::ast::Wikilink;
+
+pub(super) fn wikilink<'a>() -> impl FnMut(&'a str) -> IResult<&'a str, Wikilink> {
+ move |input: &'a str| {
+ let (input, (link, target, name)) = delimited(
+ (tag("[["), space0),
+ (
+ take_till1(|c| matches!(c, '#' | '|' | ']')),
+ opt(preceded(char('#'), take_till1(|c| matches!(c, '|' | ']')))),
+ opt(preceded(char('|'), take_till1(|c| matches!(c, ']')))),
+ ),
+ (space0, tag("]]")),
+ )
+ .parse(input)?;
+
+ let tag = Wikilink {
+ link: link.to_owned(),
+ target: target.map(ToOwned::to_owned),
+ name: name.map(ToOwned::to_owned),
+ };
+
+ Ok((input, tag))
+ }
+}
diff --git a/sable-markdown/src/parser/link_util.rs b/sable-markdown/src/parser/link_util.rs
new file mode 100644
index 0000000..ad729ec
+use nom::{
+ IResult, Parser,
+ branch::alt,
+ bytes::complete::tag,
+ character::complete::{anychar, char, none_of, one_of, satisfy},
+ combinator::{map, not, peek, recognize, value, verify},
+ multi::{fold_many0, many0, many1},
+ sequence::{delimited, preceded},
+};
+
+pub(super) fn link_label<'a>() -> impl FnMut(&'a str) -> IResult<&'a str, Vec<crate::ast::Inline>> {
+ move |input: &'a str| delimited(tag("["), link_label_inner(), tag("]")).parse(input)
+}
+
+fn link_label_inner<'a>() -> impl FnMut(&'a str) -> IResult<&'a str, Vec<crate::ast::Inline>> {
+ move |input: &'a str| {
+ let (input, label_chars) = verify(
+ many1(preceded(
+ peek(not(char(']'))),
+ alt((value(']', tag("\\]")), anychar)),
+ )),
+ |chars: &[char]| chars.iter().any(|&c| c != ' ' && c != '\n') && chars.len() < 1000,
+ )
+ .parse(input)?;
+
+ let label = label_chars.iter().collect::<String>();
+
+ let (_, label) = crate::parser::inline::inline_many1()
+ .parse(label.as_str())
+ .map_err(|err| err.map_input(|_| input))?;
+
+ Ok((input, label))
+ }
+}
+
+pub(super) fn link_title(input: &str) -> IResult<&str, String> {
+ alt((
+ link_title_double_quoted,
+ link_title_single_quoted,
+ link_title_parenthesized,
+ ))
+ .parse(input)
+}
+
+fn link_title_parenthesized(input: &str) -> IResult<&str, String> {
+ delimited(char('('), link_title_inner(')'), char(')')).parse(input)
+}
+
+fn link_title_single_quoted(input: &str) -> IResult<&str, String> {
+ delimited(char('\''), link_title_inner('\''), char('\'')).parse(input)
+}
+
+fn link_title_double_quoted(input: &str) -> IResult<&str, String> {
+ delimited(tag("\""), link_title_inner('"'), tag("\"")).parse(input)
+}
+
+fn link_title_inner(end_delim: char) -> impl FnMut(&str) -> IResult<&str, String> {
+ move |input: &str| {
+ fold_many0(
+ alt((
+ map(escaped_char, |c| c.to_string()),
+ map(none_of(&[end_delim, '\\'][..]), |c| c.to_string()),
+ )),
+ String::new,
+ |mut acc, s| {
+ acc.push_str(&s);
+ acc
+ },
+ )
+ .parse(input)
+ }
+}
+
+fn escaped_char(input: &str) -> IResult<&str, char> {
+ preceded(tag("\\"), anychar).parse(input)
+}
+
+pub(super) fn link_destination(input: &str) -> IResult<&str, String> {
+ alt((link_destination1, link_destination2)).parse(input)
+}
+
+fn link_destination1(input: &str) -> IResult<&str, String> {
+ let (input, _) = char('<').parse(input)?;
+
+ let (input, chars) = many0(alt((
+ preceded(char('\\'), one_of("<>")),
+ preceded(peek(not(one_of("\n<>"))), anychar),
+ )))
+ .parse(input)?;
+ let (input, _) = char('>').parse(input)?;
+
+ let v: String = chars.iter().collect();
+
+ Ok((input, v))
+}
+
+fn link_destination2(input: &str) -> IResult<&str, String> {
+ let (input, _) = peek(satisfy(|c| is_valid_char(c) && c != '<')).parse(input)?;
+
+ map(
+ recognize(many1(alt((
+ value((), escaped_char),
+ value((), balanced_parens),
+ value((), satisfy(|c| is_valid_char(c) && c != '(' && c != ')')),
+ )))),
+ |s: &str| s.to_string(),
+ )
+ .parse(input)
+}
+
+fn balanced_parens(input: &str) -> IResult<&str, String> {
+ delimited(
+ tag("("),
+ map(
+ fold_many0(
+ alt((
+ map(escaped_char, |c| c.to_string()),
+ map(balanced_parens, |s| format!("({s})")),
+ map(satisfy(|c| is_valid_char(c) && c != '(' && c != ')'), |c| {
+ c.to_string()
+ }),
+ )),
+ String::new,
+ |mut acc, item| {
+ acc.push_str(&item);
+ acc
+ },
+ ),
+ |s| s,
+ ),
+ tag(")"),
+ )
+ .parse(input)
+}
+
+const fn is_valid_char(c: char) -> bool {
+ !c.is_ascii_control() && c != ' ' && c != '<'
+}
diff --git a/sable-markdown/src/parser/mod.rs b/sable-markdown/src/parser/mod.rs
new file mode 100644
index 0000000..0ea3cbd
+mod blocks;
+mod inline;
+mod link_util;
+mod util;
+
+use nom::{
+ Parser,
+ branch::alt,
+ character::complete::{line_ending, space1},
+ combinator::eof,
+ multi::many0,
+ sequence::terminated,
+};
+
+use crate::ast::Document;
+
+/// Parse the given Markdown string into an AST.
+pub fn parse_markdown(input: &str) -> Result<Document, nom::Err<nom::error::Error<&str>>> {
+ let empty_lines = many0(alt((space1, line_ending)));
+ let mut parser = terminated(many0(crate::parser::blocks::block()), (empty_lines, eof));
+ let (_, blocks) = parser.parse(input)?;
+
+ let blocks = blocks.into_iter().flatten().collect();
+
+ Ok(Document { blocks })
+}
diff --git a/sable-markdown/src/parser/util.rs b/sable-markdown/src/parser/util.rs
new file mode 100644
index 0000000..c9cf2f1
+use std::{collections::HashMap, sync::LazyLock};
+
+use nom::{
+ IResult, Parser,
+ branch::alt,
+ character::complete::{anychar, line_ending, not_line_ending, space0},
+ combinator::{eof, not, recognize},
+ multi::{many0, many1},
+ sequence::{preceded, terminated},
+};
+
+pub(super) static ENTITIES: LazyLock<HashMap<String, &'static entities::Entity>> =
+ LazyLock::new(|| {
+ let mut map = HashMap::new();
+ for entity in &entities::ENTITIES {
+ map.insert(entity.entity.to_string(), entity);
+ }
+ map
+ });
+
+pub(super) fn eof_or_eol(input: &str) -> IResult<&str, &str> {
+ alt((line_ending, eof)).parse(input)
+}
+
+pub(super) fn many_empty_lines0(input: &str) -> IResult<&str, Vec<&str>> {
+ many0(preceded(space0, eof_or_eol)).parse(input)
+}
+
+pub(super) fn not_eof_or_eol1(input: &str) -> IResult<&str, &str> {
+ recognize(many1(preceded(not(eof_or_eol), anychar))).parse(input)
+}
+
+pub(super) fn not_eof_or_eol0(input: &str) -> IResult<&str, &str> {
+ alt((not_line_ending, eof)).parse(input)
+}
+
+pub(super) fn line_terminated<'a, O, P>(
+ inner: P,
+) -> impl Parser<&'a str, Output = O, Error = nom::error::Error<&'a str>>
+where
+ P: Parser<&'a str, Output = O, Error = nom::error::Error<&'a str>>,
+{
+ terminated(inner, eof_or_eol)
+}
+
+// pub(crate) fn logged<'a, O, P>(
+// message: &'static str,
+// mut inner: P,
+// ) -> impl Parser<&'a str, Output = O, Error = nom::error::Error<&'a str>>
+// where
+// P: Parser<&'a str, Output = O, Error = nom::error::Error<&'a str>>,
+// O: std::fmt::Debug,
+// {
+// move |input: &'a str| {
+// println!("Logged: {message}: {:?}", input);
+// let r = inner.parse(input);
+// println!("Logged out: {message}: {:?}", r);
+// r
+// }
+// }
diff --git a/sable-markdown/src/render/block.rs b/sable-markdown/src/render/block.rs
new file mode 100644
index 0000000..523c90d
+use heck::ToTitleCase as _;
+use pretty::{Arena, DocAllocator, DocBuilder};
+
+use crate::{
+ ast::{
+ Alignment, Block, Callout, CodeBlock, FootnoteDefinition, HeadingKind, List,
+ ListBulletKind, ListItem, ListKind, ListOrderedKindOptions, SetextHeading, Table, TableRow,
+ TaskState,
+ },
+ render::{Config, Context, ToDoc, highlighter, util::ArenaExt as _},
+};
+
+impl<'a> ToDoc<'a> for Vec<Block> {
+ fn to_doc(
+ &self,
+ config: &'a Config<'a>,
+ context: &'a Context,
+ arena: &'a Arena<'a>,
+ ) -> DocBuilder<'a, Arena<'a>, ()> {
+ arena.concat(
+ self.iter()
+ .map(|block| block.to_doc(config, context, arena)),
+ )
+ }
+}
+
+impl<'a> ToDoc<'a> for Vec<&Block> {
+ fn to_doc(
+ &self,
+ config: &'a Config<'a>,
+ context: &'a Context,
+ arena: &'a Arena<'a>,
+ ) -> DocBuilder<'a, Arena<'a>, ()> {
+ arena.concat(
+ self.iter()
+ .map(|block| block.to_doc(config, context, arena)),
+ )
+ }
+}
+
+impl<'a> ToDoc<'a> for Block {
+ fn to_doc(
+ &self,
+ config: &'a Config<'a>,
+ context: &'a Context,
+ arena: &'a Arena<'a>,
+ ) -> DocBuilder<'a, Arena<'a>, ()> {
+ match self {
+ Self::Paragraph(inlines) => {
+ let inner = arena.concat(
+ inlines
+ .iter()
+ .map(|inline| inline.to_doc(config, context, arena)),
+ );
+ arena.tag("p", vec![], inner)
+ }
+ // TODO: add heading id
+ Self::Heading(v) => {
+ let htag = match v.kind {
+ HeadingKind::Atx(1) | HeadingKind::Setext(SetextHeading::Level1) => "h1",
+ HeadingKind::Atx(2) | HeadingKind::Setext(SetextHeading::Level2) => "h2",
+ HeadingKind::Atx(3) => "h3",
+ HeadingKind::Atx(4) => "h4",
+ HeadingKind::Atx(5) => "h5",
+ HeadingKind::Atx(_) => "h6",
+ };
+ let inner = arena.concat(
+ v.content
+ .iter()
+ .map(|inline| inline.to_doc(config, context, arena)),
+ );
+ arena.tag(htag, vec![], inner)
+ }
+ Self::ThematicBreak => arena.tag("hr", vec![], arena.nil()),
+ Self::BlockQuote(inner) => {
+ let inner = arena.concat(
+ inner
+ .iter()
+ .map(|inline| inline.to_doc(config, context, arena)),
+ );
+ arena.tag("blockquote", vec![], inner)
+ }
+ Self::List(v) => v.to_doc(config, context, arena),
+ Self::CodeBlock(v) => v.to_doc(config, context, arena),
+ Self::HtmlBlock(html) => arena.text(html.clone()),
+ Self::Table(v) => v.to_doc(config, context, arena),
+ Self::FootnoteDefinition(def) => def.to_doc(config, context, arena),
+ Self::Callout(callout) => callout.to_doc(config, context, arena),
+ Self::Empty | Self::Definition(_) => arena.nil(),
+ }
+ }
+}
+
+impl<'a> ToDoc<'a> for List {
+ fn to_doc(
+ &self,
+ config: &'a Config<'a>,
+ context: &'a Context,
+ arena: &'a Arena<'a>,
+ ) -> DocBuilder<'a, Arena<'a>, ()> {
+ let items = arena.concat(
+ self.items
+ .iter()
+ .map(|item| item.to_doc(config, context, arena)),
+ );
+ match self.kind {
+ ListKind::Ordered(ListOrderedKindOptions { start }) => {
+ arena.tag("ol", vec![("start".to_owned(), format!("{start}"))], items)
+ }
+ ListKind::Bullet(kind) => {
+ let style = match kind {
+ ListBulletKind::Dash => "list-kind-dash",
+ ListBulletKind::Star => "list-kind-star",
+ ListBulletKind::Plus => "list-kind-plus",
+ };
+ arena.tag("ul", vec![("class".to_owned(), style.to_owned())], items)
+ }
+ }
+ }
+}
+
+impl<'a> ToDoc<'a> for ListItem {
+ fn to_doc(
+ &self,
+ config: &'a Config<'a>,
+ context: &'a Context,
+ arena: &'a Arena<'a>,
+ ) -> DocBuilder<'a, Arena<'a>, ()> {
+ let task = match self.task {
+ Some(TaskState::Complete) => arena.tag(
+ "span",
+ vec![("class".to_owned(), "list-task-complete".to_owned())],
+ arena.text("[X] "),
+ ),
+ Some(TaskState::Incomplete) => arena.tag(
+ "span",
+ vec![("class".to_owned(), "list-task-incomplete".to_owned())],
+ arena.text("[ ] "),
+ ),
+ None => arena.nil(),
+ };
+ let content = task.append(
+ arena.concat(
+ self.blocks
+ .iter()
+ .map(|block| block.to_doc(config, context, arena)),
+ ),
+ );
+
+ arena.tag("li", vec![], content)
+ }
+}
+
+impl<'a> ToDoc<'a> for CodeBlock {
+ fn to_doc(
+ &self,
+ _config: &'a Config<'a>,
+ _context: &'a Context,
+ arena: &'a Arena<'a>,
+ ) -> DocBuilder<'a, Arena<'a>, ()> {
+ let code = highlighter::highlight(self);
+
+ arena.tag("pre", vec![], arena.tag("code", vec![], arena.text(code)))
+ }
+}
+
+impl<'a> ToDoc<'a> for Table {
+ fn to_doc(
+ &self,
+ config: &'a Config<'a>,
+ context: &'a Context,
+ arena: &'a Arena<'a>,
+ ) -> DocBuilder<'a, Arena<'a>, ()> {
+ let first_row = table_row_to_doc(
+ config,
+ context,
+ arena,
+ self.rows.first().unwrap(),
+ "th",
+ &self.alignments,
+ );
+ let mut acc = arena.nil();
+ for row in self.rows.iter().skip(1) {
+ acc = acc.append(table_row_to_doc(
+ config,
+ context,
+ arena,
+ row,
+ "td",
+ &self.alignments,
+ ));
+ }
+
+ let content = arena
+ .tag("thead", vec![], first_row)
+ .append(arena.tag("tbody", vec![], acc));
+
+ arena.tag("table", vec![], content)
+ }
+}
+
+fn table_row_to_doc<'a>(
+ config: &'a Config<'a>,
+ context: &'a Context,
+ arena: &'a Arena<'a>,
+ row: &TableRow,
+ row_tag: &'static str,
+ alignments: &[Alignment],
+) -> DocBuilder<'a, Arena<'a>, ()> {
+ let mut acc = arena.nil();
+ for (i, cell) in row.iter().enumerate() {
+ let alignment = match alignments.get(i) {
+ None | Some(Alignment::Left | Alignment::None) => "left",
+ Some(Alignment::Right) => "right",
+ Some(Alignment::Center) => "center",
+ };
+ let alignment_class = format!("table-align-{alignment}");
+ let attributes = vec![("class".to_owned(), alignment_class)];
+ acc = acc.append(arena.tag(row_tag, attributes, cell.to_doc(config, context, arena)));
+ }
+
+ arena.tag("tr", vec![], acc)
+}
+
+impl<'a> ToDoc<'a> for FootnoteDefinition {
+ fn to_doc(
+ &self,
+ config: &'a Config<'a>,
+ context: &'a Context,
+ arena: &'a Arena<'a>,
+ ) -> DocBuilder<'a, Arena<'a>, ()> {
+ let Some(index) = context.get_footnote_index(&self.label) else {
+ return arena.nil();
+ };
+
+ arena.tag(
+ "div",
+ vec![
+ ("class".to_owned(), "footnote-definition".to_owned()),
+ ("id".to_owned(), format!("footnote-{index}")),
+ ],
+ arena
+ .tag(
+ "span",
+ vec![("class".to_owned(), "footnote-definition-index".to_owned())],
+ arena.text(format!("{index}. ")),
+ )
+ .append(arena.tag(
+ "span",
+ vec![("class".to_owned(), "footnote-definition-content".to_owned())],
+ self.blocks.to_doc(config, context, arena),
+ )),
+ )
+ }
+}
+
+impl<'a> ToDoc<'a> for Callout {
+ fn to_doc(
+ &self,
+ config: &'a Config<'a>,
+ context: &'a Context,
+ arena: &'a Arena<'a>,
+ ) -> DocBuilder<'a, Arena<'a>, ()> {
+ arena.tag(
+ "div",
+ vec![
+ ("class".to_owned(), "callout".to_owned()),
+ ("data-callout".to_owned(), "info".to_owned()),
+ ],
+ arena
+ .tag(
+ "div",
+ vec![("class".to_owned(), "callout-title".to_owned())],
+ arena
+ .tag(
+ "div",
+ vec![("class".to_owned(), "callout-title-icon".to_owned())],
+ arena.nil(),
+ )
+ .append(
+ arena.tag(
+ "div",
+ vec![("class".to_owned(), "callout-title-inner".to_owned())],
+ arena.text(
+ self.title
+ .clone()
+ .unwrap_or_else(|| self.level.to_title_case()),
+ ),
+ ),
+ ),
+ )
+ .append(arena.tag(
+ "div",
+ vec![("class".to_owned(), "callout-content".to_owned())],
+ self.blocks.to_doc(config, context, arena),
+ )),
+ )
+ }
+}
diff --git a/sable-markdown/src/render/highlighter.rs b/sable-markdown/src/render/highlighter.rs
new file mode 100644
index 0000000..20e0254
+use crate::ast::{CodeBlock, CodeBlockKind};
+
+// TODO: maybe combine the support lang tokens for all highlighters
+
+#[cfg(feature = "highlighting-inkjet")]
+pub(super) fn highlight(block: &CodeBlock) -> String {
+ use std::cell::RefCell;
+
+ use inkjet::{Highlighter, Language, formatter::Html};
+
+ let language = match &block.kind {
+ CodeBlockKind::Indented => Language::Plaintext,
+ CodeBlockKind::Fenced { info } => info
+ .as_deref()
+ .and_then(Language::from_token)
+ .unwrap_or(Language::Plaintext),
+ };
+
+ let mut buf = String::new();
+
+ thread_local! {
+ static HIGHLIGHTER: RefCell<Highlighter> = RefCell::new(Highlighter::new());
+ }
+
+ // TODO: return error probably
+ HIGHLIGHTER.with(|this| {
+ this.borrow_mut()
+ .highlight_to_fmt(language, &Html, &block.literal, &mut buf)
+ .expect("failed to write highlighted code");
+ });
+
+ buf
+}
+
+#[cfg(feature = "highlighting-syntect")]
+pub fn highlight(block: &CodeBlock) -> String {
+ use std::sync::LazyLock;
+
+ use syntect::{
+ html::{ClassStyle, ClassedHTMLGenerator},
+ parsing::SyntaxSet,
+ util::LinesWithEndings,
+ };
+
+ static SET: LazyLock<SyntaxSet> = LazyLock::new(SyntaxSet::load_defaults_newlines);
+
+ let syntax_ref = match &block.kind {
+ CodeBlockKind::Indented => SET.find_syntax_plain_text(),
+ CodeBlockKind::Fenced { info } => info
+ .as_deref()
+ .and_then(|token| SET.find_syntax_by_token(token))
+ .unwrap_or_else(|| SET.find_syntax_plain_text()),
+ };
+
+ let mut rs_html_generator =
+ ClassedHTMLGenerator::new_with_class_style(syntax_ref, &SET, ClassStyle::Spaced);
+
+ for line in LinesWithEndings::from(&block.literal) {
+ rs_html_generator
+ .parse_html_for_line_which_includes_newline(line)
+ .unwrap();
+ }
+
+ rs_html_generator.finalize()
+}
+
+#[cfg(not(any(feature = "highlighting-inkjet", feature = "highlighting-syntect")))]
+pub fn highlight(block: &CodeBlock) -> String {
+ crate::util::escape(&block.literal)
+}
diff --git a/sable-markdown/src/render/index.rs b/sable-markdown/src/render/index.rs
new file mode 100644
index 0000000..eae5dc7
+use std::collections::HashMap;
+
+use crate::ast::{Block, Document, Inline, LinkDefinition};
+
+struct Index {
+ footnote_indices: HashMap<String, usize>,
+ link_definitions: HashMap<Vec<Inline>, LinkDefinition>,
+ last_footnote_index: usize,
+}
+
+impl Index {
+ pub(crate) fn new() -> Self {
+ Self {
+ footnote_indices: HashMap::new(),
+ link_definitions: HashMap::new(),
+ last_footnote_index: 1,
+ }
+ }
+
+ pub(crate) fn add_footnote(&mut self, label: String) {
+ if let std::collections::hash_map::Entry::Vacant(e) = self.footnote_indices.entry(label) {
+ e.insert(self.last_footnote_index);
+ self.last_footnote_index += 1;
+ }
+ }
+}
+
+pub(super) fn get_indicies(
+ ast: &Document,
+) -> (HashMap<String, usize>, HashMap<Vec<Inline>, LinkDefinition>) {
+ let mut index = Index::new();
+
+ for block in &ast.blocks {
+ get_block_indicies(&mut index, block);
+ }
+
+ (index.footnote_indices, index.link_definitions)
+}
+
+fn get_block_indicies(index: &mut Index, block: &Block) {
+ match block {
+ Block::Paragraph(v) => {
+ for inline in v {
+ get_inline_indicies(index, inline);
+ }
+ }
+ Block::Heading(v) => {
+ for inline in &v.content {
+ get_inline_indicies(index, inline);
+ }
+ }
+ Block::BlockQuote(v) => {
+ for block in v {
+ get_block_indicies(index, block);
+ }
+ }
+ Block::List(v) => {
+ for item in &v.items {
+ for block in &item.blocks {
+ get_block_indicies(index, block);
+ }
+ }
+ }
+ Block::Definition(v) => {
+ index.link_definitions.insert(v.label.clone(), v.clone());
+ for inline in &v.label {
+ get_inline_indicies(index, inline);
+ }
+ }
+ Block::Table(v) => {
+ for row in &v.rows {
+ for cell in row {
+ for inline in cell {
+ get_inline_indicies(index, inline);
+ }
+ }
+ }
+ }
+ Block::FootnoteDefinition(v) => {
+ for block in &v.blocks {
+ get_block_indicies(index, block);
+ }
+ }
+ Block::Callout(v) => {
+ for block in &v.blocks {
+ get_block_indicies(index, block);
+ }
+ }
+ Block::Empty | Block::CodeBlock(_) | Block::HtmlBlock(_) | Block::ThematicBreak => (),
+ }
+}
+
+fn get_inline_indicies(index: &mut Index, inline: &Inline) {
+ if let Inline::FootnoteReference(label) = inline {
+ index.add_footnote(label.clone());
+ }
+}
diff --git a/sable-markdown/src/render/inline.rs b/sable-markdown/src/render/inline.rs
new file mode 100644
index 0000000..5a1ecd9
+use pretty::{Arena, DocAllocator, DocBuilder};
+
+use crate::{
+ ast::{Image, Inline, Link, LinkDefinition, Tag, Wikilink},
+ render::{
+ Config, Context, ToDoc,
+ util::{ArenaExt as _, escape},
+ },
+};
+
+impl<'a> ToDoc<'a> for Vec<Inline> {
+ fn to_doc(
+ &self,
+ config: &'a Config<'a>,
+ context: &'a Context,
+ arena: &'a Arena<'a>,
+ ) -> DocBuilder<'a, Arena<'a>, ()> {
+ arena.concat(
+ self.iter()
+ .map(|inline| inline.to_doc(config, context, arena))
+ .collect::<Vec<_>>(),
+ )
+ }
+}
+
+impl<'a> ToDoc<'a> for Inline {
+ fn to_doc(
+ &self,
+ config: &'a Config<'a>,
+ context: &'a Context,
+ arena: &'a Arena<'a>,
+ ) -> DocBuilder<'a, Arena<'a>, ()> {
+ match self {
+ Self::Text(t) => arena.text(escape(t)),
+ Self::LineBreak => arena.tag("br", vec![], arena.nil()),
+ Self::Code(code) => arena.tag("code", vec![], arena.text(escape(code))),
+ Self::Html(html) => arena.text(html.clone()),
+ Self::Emphasis(children) => {
+ arena.tag("em", vec![], children.to_doc(config, context, arena))
+ }
+ Self::Strong(children) => {
+ arena.tag("b", vec![], children.to_doc(config, context, arena))
+ }
+ Self::Strikethrough(children) => {
+ arena.tag("s", vec![], children.to_doc(config, context, arena))
+ }
+ Self::Tag(Tag { text }) => arena.tag(
+ "a",
+ vec![("data-tag".to_owned(), String::new())],
+ arena.text(text.to_owned()),
+ ),
+ Self::Wikilink(Wikilink { link, target, name }) => {
+ let destination = config.rewrite_wikilink(link, target.as_deref());
+
+ arena.tag(
+ "a",
+ vec![("href".to_owned(), escape(&destination))],
+ arena.text(name.as_ref().unwrap_or(link).to_owned()),
+ )
+ }
+ Self::Link(Link {
+ destination,
+ title,
+ children,
+ }) => {
+ let destination = config.rewrite_link(destination, None);
+ let mut attributes = vec![("href".to_owned(), escape(&destination))];
+ if let Some(title) = title {
+ attributes.push(("title".to_owned(), escape(title)));
+ }
+ arena.tag("a", attributes, children.to_doc(config, context, arena))
+ }
+ Self::Image(Image {
+ destination,
+ title,
+ alt,
+ }) => {
+ let mut attributes = vec![
+ ("src".to_owned(), escape(destination)),
+ ("alt".to_owned(), escape(alt)),
+ ];
+ if let Some(title) = title {
+ attributes.push(("title".to_owned(), escape(title)));
+ }
+ arena.tag("img", attributes, arena.nil())
+ }
+ Self::Autolink(link) => {
+ let destination = config.rewrite_link(link, None);
+
+ arena.tag(
+ "a",
+ vec![("href".to_owned(), escape(&destination))],
+ arena.text(escape(link)),
+ )
+ }
+ Self::FootnoteReference(label) => {
+ let Some(index) = context.get_footnote_index(label) else {
+ return arena.nil();
+ };
+ arena.tag(
+ "a",
+ vec![
+ ("class".to_owned(), "footnote-reference".to_owned()),
+ ("href".to_owned(), escape(&format!("#footnote-{index}"))),
+ ],
+ arena.text(format!("[{index}]")),
+ )
+ }
+ Self::LinkReference(v) => {
+ let definition: &LinkDefinition = match context.get_link_definition(&v.label) {
+ Some(v) => v,
+ None => return arena.nil(),
+ };
+ let destination = config.rewrite_link(&definition.destination, None);
+ let mut attributes = vec![("href".to_owned(), escape(&destination))];
+ if let Some(title) = &definition.title {
+ attributes.push(("title".to_owned(), escape(title)));
+ }
+ arena.tag("a", attributes, v.text.to_doc(config, context, arena))
+ }
+ Self::Empty => arena.nil(),
+ }
+ }
+}
diff --git a/sable-markdown/src/render/mod.rs b/sable-markdown/src/render/mod.rs
new file mode 100644
index 0000000..e28ee56
+mod block;
+mod highlighter;
+mod index;
+mod inline;
+mod tests;
+mod util;
+
+use std::collections::HashMap;
+
+use pretty::{Arena, DocBuilder};
+
+use crate::ast::{Document, Inline, LinkDefinition};
+
+pub trait UrlRewriter {
+ /// Rewrite a given URL to someplace new.
+ ///
+ /// `target` is only used for Wikilinks currently.
+ fn rewrite(&self, path: &str, target: Option<&str>) -> String;
+}
+
+pub struct Config<'a> {
+ pub(crate) width: usize,
+ pub(crate) link_rewriter: Option<Box<dyn UrlRewriter + 'a>>,
+ pub(crate) wikilink_rewriter: Option<Box<dyn UrlRewriter + 'a>>,
+}
+
+impl Default for Config<'_> {
+ fn default() -> Self {
+ Self {
+ width: 80,
+ link_rewriter: None,
+ wikilink_rewriter: None,
+ }
+ }
+}
+
+impl<'a> Config<'a> {
+ #[must_use]
+ pub fn with_width(self, width: usize) -> Self {
+ Self { width, ..self }
+ }
+
+ #[must_use]
+ pub fn with_link_rewriter<R: UrlRewriter + 'a>(self, url_rewriter: R) -> Self {
+ Self {
+ link_rewriter: Some(Box::new(url_rewriter)),
+ ..self
+ }
+ }
+
+ #[must_use]
+ pub fn with_wikilink_rewriter<R: UrlRewriter + 'a>(self, url_rewriter: R) -> Self {
+ Self {
+ wikilink_rewriter: Some(Box::new(url_rewriter)),
+ ..self
+ }
+ }
+
+ pub(crate) fn rewrite_link(&self, path: &str, target: Option<&str>) -> String {
+ if let Some(rewriter) = &self.link_rewriter {
+ return rewriter.rewrite(path, target);
+ }
+
+ path.to_owned()
+ }
+
+ pub(crate) fn rewrite_wikilink(&self, path: &str, target: Option<&str>) -> String {
+ if let Some(rewriter) = &self.wikilink_rewriter {
+ return rewriter.rewrite(path, target);
+ }
+
+ path.to_owned()
+ }
+}
+
+pub(crate) struct Context {
+ // Mapping of footnote labels to their indices in the footnote list.
+ footnote_index: HashMap<String, usize>,
+ // Mapping of link labels to their definitions.
+ link_definitions: HashMap<Vec<Inline>, LinkDefinition>,
+}
+
+impl Context {
+ pub(crate) fn new(ast: &Document) -> Self {
+ let (footnote_index, link_definitions) = index::get_indicies(ast);
+ Self {
+ footnote_index,
+ link_definitions,
+ }
+ }
+
+ pub(crate) fn get_footnote_index(&self, label: &str) -> Option<&usize> {
+ self.footnote_index.get(label)
+ }
+
+ pub(crate) fn get_link_definition(&self, label: &Vec<Inline>) -> Option<&LinkDefinition> {
+ self.link_definitions.get(label)
+ }
+}
+
+/// Render the given Markdown AST to HTML.
+#[must_use]
+pub fn render_html(ast: &Document, config: &Config<'_>) -> String {
+ let mut buf = Vec::new();
+
+ {
+ let width = config.width;
+
+ let context = Context::new(ast);
+ let arena = Arena::new();
+ ast.to_doc(config, &context, &arena)
+ .render(width, &mut buf)
+ .unwrap();
+ }
+
+ String::from_utf8(buf).unwrap()
+}
+
+trait ToDoc<'a> {
+ fn to_doc(
+ &self,
+ config: &'a Config<'a>,
+ context: &'a Context,
+ arena: &'a Arena<'a>,
+ ) -> DocBuilder<'a, Arena<'a>, ()>;
+}
+
+impl<'a> ToDoc<'a> for Document {
+ fn to_doc(
+ &self,
+ config: &'a Config<'a>,
+ context: &'a Context,
+ arena: &'a Arena<'a>,
+ ) -> DocBuilder<'a, Arena<'a>, ()> {
+ self.blocks.to_doc(config, context, arena)
+ }
+}
diff --git a/sable-markdown/src/render/tests/mod.rs b/sable-markdown/src/render/tests/mod.rs
new file mode 100644
index 0000000..4b8202e
+#![cfg(test)]
+use rstest::rstest;
+
+#[rstest]
+#[case("Hello, world!", "<p>Hello, world!</p>")]
+#[case("Hello, **world**!", "<p>Hello, <b>world</b>!</p>")]
+#[case("Hello, *world*!", "<p>Hello, <em>world</em>!</p>")]
+#[case("Hello, __world__!", "<p>Hello, <b>world</b>!</p>")]
+#[case("Hello, _world_!", "<p>Hello, <em>world</em>!</p>")]
+#[case("Hello, ~~world~~!", "<p>Hello, <s>world</s>!</p>")]
+#[case(
+ "1. Item 1\n2. Item 2",
+ "<ol start=\"1\"><li><p>Item 1</p></li><li><p>Item 2</p></li></ol>"
+)]
+#[case(
+ "* Item 1\n* Item 2",
+ "<ul class=\"list-kind-star\"><li><p>Item 1</p></li><li><p>Item 2</p></li></ul>"
+)]
+#[case("`code`", "<p><code>code</code></p>")]
+// #[case("```rust\nfn main() {}\n```", "<pre><code>fn main() {}</code></pre>")] // TODO: handle syntax highlighting in tests
+#[case(
+ "[Google][1]\n\n[1]: https://www.google.com 'Search engine'",
+ "<p><a href=\"https://www.google.com\" title=\"Search engine\">Google</a></p>"
+)]
+#[case(
+ "Hello[^1]\n\n[^1]: This is a footnote.",
+ "<p>Hello<a class=\"footnote-reference\" href=\"#footnote-1\">[1]</a></p><div class=\"footnote-definition\" id=\"footnote-1\"><span class=\"footnote-definition-index\">1. </span><span class=\"footnote-definition-content\"><p>This is a footnote.</p></span></div>"
+)]
+#[case(
+ "",
+ "<p><img src=\"https://example.com/image.png\" alt=\"alt text\"></img></p>"
+)]
+#[case(
+ "| Header 1 | Header 2 |
+| --- | --: |
+| Row 1 Col 1 | Row 1 Col 2 |
+| Row 2 Col 1 | Col 2 |",
+ "<table><thead><tr><th class=\"table-align-left\">Header 1</th><th class=\"table-align-right\">Header 2</th></tr></thead><tbody><tr><td class=\"table-align-left\">Row 1 Col 1</td><td class=\"table-align-right\">Row 1 Col 2</td></tr><tr><td class=\"table-align-left\">Row 2 Col 1</td><td class=\"table-align-right\">Col 2</td></tr></tbody></table>"
+)]
+fn render_to_html(#[case] input: &str, #[case] expected: &str) {
+ let config = crate::render::Config::default();
+ let ast = crate::parser::parse_markdown(input).unwrap();
+ println!("{input:?} => {ast:#?}");
+ let result = crate::render::render_html(&ast, &config);
+ assert_eq!(expected, result);
+}
diff --git a/sable-markdown/src/render/util.rs b/sable-markdown/src/render/util.rs
new file mode 100644
index 0000000..85e7803
+use pretty::{Arena, DocAllocator, DocBuilder};
+
+pub(super) fn escape(value: &str) -> String {
+ let mut escaped = String::new();
+ for c in value.chars() {
+ match c {
+ '&' => escaped.push_str("&"),
+ '<' => escaped.push_str("<"),
+ '>' => escaped.push_str(">"),
+ '"' => escaped.push_str("""),
+ '\'' => escaped.push_str("'"),
+ _ => escaped.push(c),
+ }
+ }
+ escaped
+}
+
+pub(super) trait ArenaExt<'a> {
+ fn tag(
+ &'a self,
+ tag: &'static str,
+ attributes: Vec<(String, String)>,
+ inner: DocBuilder<'a, Arena<'a>, ()>,
+ ) -> DocBuilder<'a, Arena<'a>, ()>;
+}
+
+impl<'a> ArenaExt<'a> for Arena<'a> {
+ fn tag(
+ &'a self,
+ tag: &'static str,
+ attributes: Vec<(String, String)>,
+ inner: DocBuilder<'a, Self, ()>,
+ ) -> DocBuilder<'a, Self, ()> {
+ let mut attrs = self.nil();
+ for (key, value) in attributes {
+ let attr = if value.is_empty() {
+ self.text(" ").append(self.text(key))
+ } else {
+ self.text(" ")
+ .append(self.text(key))
+ .append(self.text("=\""))
+ .append(self.text(escape(&value)))
+ .append(self.text("\""))
+ };
+
+ attrs = attrs.append(attr);
+ }
+ let open_tag = self
+ .text("<")
+ .append(self.text(tag))
+ .append(attrs)
+ .append(self.text(">"));
+ let close_tag = self
+ .text("</")
+ .append(self.text(tag))
+ .append(self.text(">"));
+ open_tag.append(inner).append(close_tag)
+ }
+}
diff --git a/sable-markdown/tests/gfm.md b/sable-markdown/tests/gfm.md
new file mode 100644
index 0000000..e69de29
diff --git a/sable-markdown/tests/gfm.rs b/sable-markdown/tests/gfm.rs
new file mode 100644
index 0000000..e69de29
diff --git a/sable-markdown/tests/ofm.md b/sable-markdown/tests/ofm.md
new file mode 100644
index 0000000..e69de29
diff --git a/sable-markdown/tests/ofm.rs b/sable-markdown/tests/ofm.rs
new file mode 100644
index 0000000..e69de29
diff --git a/sable-markdown/tests/standard.md b/sable-markdown/tests/standard.md
new file mode 100644
index 0000000..e69de29
diff --git a/sable-markdown/tests/standard.rs b/sable-markdown/tests/standard.rs
new file mode 100644
index 0000000..e69de29
diff --git a/sable-renderer/Cargo.toml b/sable-renderer/Cargo.toml
new file mode 100644
index 0000000..5817cf8
+[package]
+name = "sable-renderer"
+version = "0.1.0"
+edition = "2024"
+
+[dependencies]
+sable-core = { path = "../sable-core" }
+sable-bases = { path = "../sable-bases" }
+sable-canvas = { path = "../sable-canvas" }
+sable-markdown = { path = "../sable-markdown" }
+sable-vault = { path = "../sable-vault" }
+
+data-encoding.workspace = true
+fjadra.workspace = true
+indenter.workspace = true
+miette.workspace = true
+rustc-hash.workspace = true
+serde.workspace = true
+slug.workspace = true
+serde_json.workspace = true
+svg.workspace = true
+tera.workspace = true
+thiserror.workspace = true
+tracing.workspace = true
+url-escape.workspace = true
+itertools.workspace = true
diff --git a/sable-renderer/src/base.rs b/sable-renderer/src/base.rs
new file mode 100644
index 0000000..e1d5bec
+use crate::Renderer;
+
+impl Renderer {
+ pub fn render_bases(&self) {}
+}
diff --git a/sable-renderer/src/canvas.rs b/sable-renderer/src/canvas.rs
new file mode 100644
index 0000000..a4ddc23
+use crate::Renderer;
+
+impl Renderer {
+ pub fn render_canvases(&self) {}
+}
diff --git a/sable-renderer/src/html.rs b/sable-renderer/src/html.rs
new file mode 100644
index 0000000..38d4b51
+use sable_vault::{File, ItemPathBuf};
+
+use crate::{IntoDiagnostic as _, Renderer, RendererError};
+
+impl Renderer {
+ pub fn render_htmls(&self) {
+ let vault = self.vault.read();
+
+ for (path, file) in vault.files.iter().filter(|(_, file)| file.kind.is_html()) {
+ if let Err(err) = self.render_html(path, file).into_diagnostic() {
+ tracing::error!(path=%path, "failed to render custom page");
+ eprint!("{err:?}");
+ }
+ }
+ }
+
+ fn render_html(&self, path: &ItemPathBuf, _file: &File) -> Result<(), RendererError> {
+ tracing::warn!(path=?path.relative, "html vault file rendering is not implemented yet");
+
+ Ok(())
+ }
+}
diff --git a/sable-renderer/src/lib.rs b/sable-renderer/src/lib.rs
new file mode 100644
index 0000000..5e6817a
+#![deny(rust_2018_idioms, unsafe_code)]
+#![warn(
+ absolute_paths_not_starting_with_crate,
+ ambiguous_associated_items,
+ anonymous_parameters,
+ arithmetic_overflow,
+ array_into_iter,
+ asm_sub_register,
+ bad_asm_style,
+ bindings_with_variant_name,
+ break_with_label_and_loop,
+ clashing_extern_declarations,
+ coherence_leak_check,
+ conflicting_repr_hints,
+ confusable_idents,
+ const_evaluatable_unchecked,
+ const_item_mutation,
+ dangling_pointers_from_temporaries,
+ dead_code,
+ deprecated_in_future,
+ deprecated_where_clause_location,
+ deprecated,
+ deref_into_dyn_supertrait,
+ deref_nullptr,
+ drop_bounds,
+ duplicate_macro_attributes,
+ dyn_drop,
+ ellipsis_inclusive_range_patterns,
+ enum_intrinsics_non_enums,
+ explicit_outlives_requirements,
+ exported_private_dependencies,
+ forbidden_lint_groups,
+ function_item_references,
+ future_incompatible,
+ ill_formed_attribute_input,
+ improper_ctypes_definitions,
+ improper_ctypes,
+ incomplete_features,
+ incomplete_include,
+ ineffective_unstable_trait_impl,
+ inline_no_sanitize,
+ invalid_atomic_ordering,
+ invalid_doc_attributes,
+ invalid_type_param_default,
+ invalid_value,
+ irrefutable_let_patterns,
+ keyword_idents,
+ large_assignments,
+ late_bound_lifetime_arguments,
+ legacy_derive_helpers,
+ macro_expanded_macro_exports_accessed_by_absolute_paths,
+ meta_variable_misuse,
+ missing_abi,
+ missing_copy_implementations,
+ missing_debug_implementations,
+ missing_docs,
+ mixed_script_confusables,
+ mutable_transmutes,
+ named_arguments_used_positionally,
+ named_asm_labels,
+ no_mangle_const_items,
+ no_mangle_generic_items,
+ non_ascii_idents,
+ non_camel_case_types,
+ non_fmt_panics,
+ non_shorthand_field_patterns,
+ non_snake_case,
+ non_upper_case_globals,
+ nonstandard_style,
+ noop_method_call,
+ overflowing_literals,
+ overlapping_range_endpoints,
+ path_statements,
+ patterns_in_fns_without_body,
+ proc_macro_derive_resolution_fallback,
+ pub_use_of_private_extern_crate,
+ redundant_semicolons,
+ repr_transparent_external_private_fields,
+ rust_2021_incompatible_closure_captures,
+ rust_2021_incompatible_or_patterns,
+ rust_2021_prefixes_incompatible_syntax,
+ rust_2021_prelude_collisions,
+ semicolon_in_expressions_from_macros,
+ soft_unstable,
+ stable_features,
+ text_direction_codepoint_in_comment,
+ text_direction_codepoint_in_literal,
+ trivial_bounds,
+ trivial_casts,
+ trivial_numeric_casts,
+ type_alias_bounds,
+ tyvar_behind_raw_pointer,
+ uncommon_codepoints,
+ unconditional_panic,
+ unconditional_recursion,
+ unexpected_cfgs,
+ uninhabited_static,
+ unknown_crate_types,
+ unnameable_test_items,
+ unreachable_code,
+ unreachable_patterns,
+ unreachable_pub,
+ unsafe_op_in_unsafe_fn,
+ unstable_features,
+ unstable_name_collisions,
+ unused_allocation,
+ unused_assignments,
+ unused_attributes,
+ unused_braces,
+ unused_comparisons,
+ unused_crate_dependencies,
+ unused_doc_comments,
+ unused_extern_crates,
+ unused_features,
+ unused_import_braces,
+ unused_imports,
+ unused_labels,
+ unused_lifetimes,
+ unused_macro_rules,
+ unused_macros,
+ unused_must_use,
+ unused_mut,
+ unused_parens,
+ unused_qualifications,
+ unused_unsafe,
+ unused_variables,
+ useless_deprecated,
+ while_true
+)]
+#![warn(
+ clippy::all,
+ clippy::await_holding_lock,
+ clippy::char_lit_as_u8,
+ clippy::checked_conversions,
+ clippy::cognitive_complexity,
+ clippy::dbg_macro,
+ clippy::debug_assert_with_mut_call,
+ clippy::disallowed_script_idents,
+ clippy::doc_link_with_quotes,
+ clippy::doc_markdown,
+ clippy::empty_enum,
+ clippy::empty_line_after_outer_attr,
+ clippy::empty_structs_with_brackets,
+ clippy::enum_glob_use,
+ clippy::equatable_if_let,
+ clippy::exit,
+ clippy::expl_impl_clone_on_copy,
+ clippy::explicit_deref_methods,
+ clippy::explicit_into_iter_loop,
+ clippy::fallible_impl_from,
+ clippy::filter_map_next,
+ clippy::flat_map_option,
+ clippy::float_cmp_const,
+ clippy::float_cmp,
+ clippy::float_equality_without_abs,
+ clippy::fn_params_excessive_bools,
+ clippy::fn_to_numeric_cast_any,
+ clippy::from_iter_instead_of_collect,
+ clippy::if_let_mutex,
+ clippy::implicit_clone,
+ clippy::imprecise_flops,
+ clippy::index_refutable_slice,
+ clippy::inefficient_to_string,
+ clippy::invalid_upcast_comparisons,
+ clippy::iter_not_returning_iterator,
+ clippy::large_digit_groups,
+ clippy::large_stack_arrays,
+ clippy::large_types_passed_by_value,
+ clippy::let_unit_value,
+ clippy::linkedlist,
+ clippy::lossy_float_literal,
+ clippy::macro_use_imports,
+ clippy::manual_ok_or,
+ clippy::map_err_ignore,
+ clippy::map_flatten,
+ clippy::map_unwrap_or,
+ clippy::match_same_arms,
+ clippy::match_wild_err_arm,
+ clippy::match_wildcard_for_single_variants,
+ clippy::mem_forget,
+ clippy::missing_const_for_fn,
+ clippy::missing_enforced_import_renames,
+ clippy::missing_errors_doc,
+ clippy::missing_panics_doc,
+ clippy::mut_mut,
+ clippy::mutex_integer,
+ clippy::needless_borrow,
+ clippy::needless_continue,
+ clippy::needless_for_each,
+ clippy::needless_pass_by_value,
+ clippy::negative_feature_names,
+ clippy::nonstandard_macro_braces,
+ clippy::nursery,
+ clippy::option_if_let_else,
+ clippy::option_option,
+ clippy::path_buf_push_overwrite,
+ clippy::pedantic,
+ // clippy::print_stderr,
+ clippy::print_stdout,
+ clippy::ptr_as_ptr,
+ clippy::rc_mutex,
+ clippy::ref_option_ref,
+ clippy::rest_pat_in_fully_bound_structs,
+ clippy::same_functions_in_if_condition,
+ clippy::semicolon_if_nothing_returned,
+ clippy::shadow_unrelated,
+ clippy::similar_names,
+ clippy::single_match_else,
+ clippy::string_add_assign,
+ clippy::string_add,
+ clippy::string_lit_as_bytes,
+ clippy::suspicious_operation_groupings,
+ clippy::todo,
+ clippy::trailing_empty_array,
+ clippy::trait_duplication_in_bounds,
+ clippy::trivially_copy_pass_by_ref,
+ clippy::unimplemented,
+ clippy::unnecessary_wraps,
+ clippy::unnested_or_patterns,
+ clippy::unseparated_literal_suffix,
+ clippy::unused_self,
+ // clippy::use_debug,
+ clippy::use_self,
+ clippy::used_underscore_binding,
+ clippy::useless_let_if_seq,
+ clippy::useless_transmute,
+ clippy::verbose_file_reads,
+ clippy::wildcard_dependencies,
+ clippy::wildcard_imports,
+ clippy::zero_sized_map_values
+)]
+
+//! A simple Tera + Markdown renderer for Obsidian vaults.
+
+pub mod templates;
+
+mod base;
+mod canvas;
+mod html;
+mod note;
+mod page;
+
+use std::fmt::Write as _;
+
+use sable_core::{MetaInfo, config::Config};
+use sable_vault::{File, SharedVault};
+
+use crate::templates::{Templates, TemplatesError};
+
+#[derive(Debug, miette::Diagnostic, thiserror::Error)]
+pub enum RendererError {
+ #[error("failed to create parent directories")]
+ CreateDir(#[source] std::io::Error),
+ #[error("failed to render template")]
+ RenderTemplate(#[source] TemplatesError),
+ #[error("failed to write rendered template")]
+ WriteTemplate(#[source] std::io::Error),
+}
+
+#[derive(Debug)]
+pub struct Renderer {
+ config: Config,
+ meta_info: &'static MetaInfo,
+ vault: SharedVault,
+
+ templates: Templates,
+}
+
+impl Renderer {
+ pub const fn new(
+ config: Config,
+ meta_info: &'static MetaInfo,
+ vault: SharedVault,
+ templates: Templates,
+ ) -> Self {
+ Self {
+ config,
+ meta_info,
+ vault,
+
+ templates,
+ }
+ }
+
+ pub fn render_all(&self) {
+ self.render_bases();
+ self.render_canvases();
+ self.render_notes();
+ self.render_pages();
+ self.render_htmls();
+ }
+
+ /// Copy non-'note' assets to the destination folder.
+ pub fn copy_assets(&self) {
+ let vault = self.vault.read();
+
+ for file in vault.files.values() {
+ if !file.kind.is_asset() {
+ continue;
+ }
+
+ if let Err(err) = self.copy_asset(file).into_diagnostic() {
+ tracing::error!(path=%file.path.full, "failed to copy asset");
+ eprintln!("{err:?}");
+ }
+ }
+ }
+
+ fn copy_asset(&self, file: &File) -> std::io::Result<()> {
+ let mut path = self.config.build.join(&file.path.slug);
+
+ if let Some(dir) = path.parent() {
+ std::fs::create_dir_all(dir)?;
+ }
+
+ if let Some(ext) = file.path.extension() {
+ path.add_extension(ext);
+ }
+
+ std::fs::copy(&file.path.full, &path)?;
+
+ Ok(())
+ }
+}
+
+#[derive(Debug)]
+pub(crate) struct DiagnosticError(pub(crate) Box<dyn std::error::Error + Send + Sync + 'static>);
+
+impl std::fmt::Display for DiagnosticError {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ let msg = &self.0;
+ write!(indenter::indented(f).with_str(" "), "{msg}")
+ }
+}
+
+impl std::error::Error for DiagnosticError {
+ fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
+ self.0.source()
+ }
+}
+
+impl miette::Diagnostic for DiagnosticError {}
+
+pub(crate) trait IntoDiagnostic<T, E> {
+ fn into_diagnostic(self) -> Result<T, miette::Report>;
+}
+
+impl<T, E: std::error::Error + Send + Sync + 'static> IntoDiagnostic<T, E> for Result<T, E> {
+ fn into_diagnostic(self) -> Result<T, miette::Report> {
+ self.map_err(|e| DiagnosticError(Box::new(e)).into())
+ }
+}
+
+pub(crate) fn slugify_path(mut path: &str) -> String {
+ use itertools::Itertools as _;
+
+ fn get_extension(path: &str) -> Option<&str> {
+ if !path.contains('.') {
+ return None;
+ }
+
+ if !path.contains('/') {
+ return path.split('.').rfind(|s| !s.is_empty() || *s != ".");
+ }
+
+ path.split('/')
+ .rfind(|s| !s.is_empty() || *s != "/")
+ .and_then(|s| s.split('.').rfind(|s| !s.is_empty() || *s != "."))
+ }
+
+ let ext = get_extension(path);
+
+ if let Some(ext) = &ext {
+ path = path.trim_end_matches(ext);
+ path = path.trim_end_matches('.');
+ }
+
+ let (path, has_leading_period) = if path.starts_with('.') {
+ (path.trim_start_matches('.'), true)
+ } else {
+ (path, false)
+ };
+
+ let path = path
+ .split('/')
+ .filter(|s| !(s.is_empty() || *s == "/"))
+ .map(slug::slugify)
+ .join("/");
+
+ format!(
+ "{}{}{}{}",
+ if has_leading_period { "./" } else { "" },
+ url_escape::encode_path(path.as_str()),
+ if ext.is_some() { "." } else { "" },
+ ext.map_or("", |ext| match ext {
+ "md" => "html",
+ ext => ext,
+ }),
+ )
+}
diff --git a/sable-renderer/src/note.rs b/sable-renderer/src/note.rs
new file mode 100644
index 0000000..08c9f6f
+use sable_vault::Note;
+use tera::Context;
+
+use crate::{IntoDiagnostic as _, Renderer, RendererError};
+
+impl Renderer {
+ /// Render all notes using [`tera`] and write then to the destination folder.
+ pub fn render_notes(&self) {
+ let vault = self.vault.read();
+
+ for note in vault.notes.values() {
+ tracing::debug!(path=%note.path.full, "rendering vault note");
+
+ if note.properties.draft == Some(true) {
+ continue;
+ }
+
+ if let Err(err) = self.render_note(note).into_diagnostic() {
+ tracing::error!(path=%note.path.full, "failed to render vault note");
+ eprint!("{err:?}");
+ }
+
+ tracing::debug!(path=%note.path.full, "rendered vault note");
+ }
+ }
+
+ fn render_note(&self, note: &Note) -> Result<(), RendererError> {
+ let dest_path = self
+ .config
+ .build
+ .join(note.path.slug.with_extension("html"));
+
+ if let Some(dir) = dest_path.parent() {
+ std::fs::create_dir_all(dir).map_err(RendererError::CreateDir)?;
+ }
+
+ let template = note.template().unwrap_or("default");
+ let template = format!("{template}.html");
+
+ let mut ctx = Context::new();
+
+ ctx.insert("meta", &self.meta_info);
+ if let Some(data) = self.config.data.as_ref() {
+ ctx.insert("data", data);
+ }
+
+ ctx.insert("note", ¬e.as_context());
+
+ let rendered = self
+ .templates
+ .render(&template, &ctx)
+ .map_err(RendererError::RenderTemplate)?;
+
+ std::fs::write(dest_path, rendered).map_err(RendererError::WriteTemplate)?;
+
+ Ok(())
+ }
+}
diff --git a/sable-renderer/src/page.rs b/sable-renderer/src/page.rs
new file mode 100644
index 0000000..69b0599
+use sable_core::config::PageConfig;
+use tera::Context;
+
+use crate::{IntoDiagnostic as _, Renderer, RendererError};
+
+impl Renderer {
+ /// Render all custom pages using [`tera`] and write then to the destination folder.
+ pub fn render_pages(&self) {
+ for page in &self.config.pages {
+ if let Err(err) = self.render_page(page).into_diagnostic() {
+ tracing::error!(path=%page.path, "failed to render custom page");
+ eprintln!("{err:?}");
+ }
+ }
+ }
+
+ fn render_page(&self, page: &PageConfig) -> Result<(), RendererError> {
+ let dest_path = self.config.build.join(&page.path);
+
+ if let Some(dir) = dest_path.parent() {
+ std::fs::create_dir_all(dir).map_err(RendererError::CreateDir)?;
+ }
+
+ let mut ctx = Context::new();
+
+ ctx.insert("meta", &self.meta_info);
+ if let Some(data) = self.config.data.as_ref() {
+ ctx.insert("data", data);
+ }
+
+ let rendered = self
+ .templates
+ .render(&page.template, &ctx)
+ .map_err(RendererError::RenderTemplate)?;
+
+ std::fs::write(dest_path, rendered).map_err(RendererError::WriteTemplate)?;
+
+ Ok(())
+ }
+}
diff --git a/sable-renderer/src/templates/default.html b/sable-renderer/src/templates/default.html
new file mode 100644
index 0000000..2885ae4
+
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>{{ note.title }}</title>
+</head>
+<body>
+ {{ note.contents|markdown|safe }}
+</body>
+</html><
\ No newline at end of file
diff --git a/sable-renderer/src/templates/func_link_graph.rs b/sable-renderer/src/templates/func_link_graph.rs
new file mode 100644
index 0000000..00ade42
+use std::{collections::HashMap, fmt::Write as _, hash::Hasher};
+
+use fjadra::{
+ Link, Node, PositionX, PositionY, Simulation,
+ force::{Collide, SimulationBuilder},
+};
+use sable_vault::{Note, NoteContextOwned, SharedVault};
+use svg::{
+ Document, Node as _,
+ node::element::{Anchor, Circle, Group, Line, Text},
+};
+use tera::{Function, Value};
+
+pub(super) struct LinkGraph {
+ vault: SharedVault,
+}
+
+impl LinkGraph {
+ pub(super) const fn new(vault: SharedVault) -> Self {
+ Self { vault }
+ }
+}
+
+impl Function for LinkGraph {
+ #[allow(clippy::significant_drop_tightening)]
+ fn call(&self, args: &HashMap<String, Value>) -> tera::Result<Value> {
+ let Some(note) = args.get("note") else {
+ return Err(tera::Error::msg(
+ "Function `link_graph` was called without a `note` argument",
+ ));
+ };
+
+ // TODO: maybe use deserialize the index directly
+ let note = match serde_json::from_value::<NoteContextOwned>(note.clone()) {
+ Ok(note) => note,
+ Err(err) => {
+ return Err(tera::Error::msg(format!(
+ "Function `link_graph` received an invalid `note` argument: {err}"
+ )));
+ }
+ };
+
+ // NOTE: clippy::significant_drop_tightening
+ let read = self.vault.read();
+
+ // TODO
+ let note = read.notes.get(¬e.path).unwrap();
+
+ let references = read.get_note_references(note.index).collect::<Vec<_>>();
+
+ Ok(render_node_graph(¬e, references.as_slice()).into())
+ }
+}
+
+trait GraphNode {
+ fn name(&self) -> String;
+ fn link(&self) -> String;
+}
+
+impl GraphNode for NoteContextOwned {
+ fn name(&self) -> String {
+ self.title.clone()
+ }
+
+ fn link(&self) -> String {
+ format!("/{}.html", self.path.slug)
+ }
+}
+
+impl GraphNode for &Note {
+ fn name(&self) -> String {
+ self.title().to_string()
+ }
+
+ fn link(&self) -> String {
+ format!("/{}.html", self.path().slug)
+ }
+}
+
+#[allow(clippy::cast_precision_loss)]
+fn render_node_graph<N: GraphNode>(center: &N, nodes: &[N]) -> String {
+ let (smallest, largest, simulation) = simulate(nodes);
+
+ let elements = build_svg_elements(center, nodes, &simulation);
+
+ let mut svg = Document::new().set("class", "sable-link-graph").set(
+ "viewBox",
+ (
+ smallest[0] - 10.0,
+ smallest[1] - 10.0,
+ (largest[0].abs() + smallest[0].abs()).abs() + 20.0,
+ (largest[1].abs() + smallest[1].abs()).abs() + 20.0,
+ ),
+ );
+
+ // for node in elements.connections {
+ // svg.append(node);
+ // }
+ // for node in elements.nodes {
+ // svg.append(node);
+ // }
+ // for node in elements.titles {
+ // svg.append(node);
+ // }
+
+ // svg.append(svg::node::element::Style::new(elements.style));
+
+ for (i, pos) in simulation.positions().enumerate() {
+ if pos != [0.0, 0.0] {
+ svg.append(
+ Line::new()
+ .set("id", hash(format!("{}-{}-{}-{}", 0.0, 0.0, pos[0], pos[1])))
+ .set("class", "note-connection")
+ .set("x1", 0.0)
+ .set("y1", 0.0)
+ .set("x2", pos[0])
+ .set("y2", pos[1])
+ .set("stroke", "gray"),
+ );
+ }
+
+ let note = if i == 0 { center } else { &nodes[i - 1] };
+
+ svg.append(
+ Group::new()
+ .set("id", hash(format!("{}-{}", pos[0], pos[1])))
+ .set("class", "note-container")
+ .add(
+ Anchor::new()
+ .set("class", "note-link")
+ .set("href", note.link())
+ .add(
+ Circle::new()
+ .set("class", "note-node")
+ .set("r", 1.5)
+ .set("cx", pos[0])
+ .set("cy", pos[1])
+ .set("fill", "black"),
+ ),
+ )
+ .add(
+ Text::new(note.name())
+ .set("class", "note-text")
+ .set("x", pos[0] + 2.0)
+ .set("y", pos[1] + 0.65),
+ ),
+ );
+ }
+
+ svg.to_string()
+}
+
+fn simulate<N: GraphNode>(nodes: &[N]) -> ([f64; 2], [f64; 2], Simulation) {
+ let angle = nodes.len() as f64 / 360.0;
+
+ let links = nodes.iter().enumerate().map(|(i, _)| (0, i + 1));
+
+ let mut simulation = SimulationBuilder::default()
+ .with_velocity_decay(0.1)
+ .build(
+ std::iter::once(Node::from([0.0, 0.0]).fixed_position(0.0, 0.0)).chain(
+ (0..nodes.len()).enumerate().map(|(i, _)| {
+ Node::from([
+ 1.0 * (angle * i as f64).cos(),
+ 1.0 * (angle * i as f64).sin(),
+ ])
+ }),
+ ),
+ )
+ .add_force("collide", Collide::new().radius(|_| 5.0).iterations(3))
+ .add_force(
+ "link",
+ Link::new(links).strength(1.0).distance(10.0).iterations(10),
+ )
+ .add_force("x", PositionX::new())
+ .add_force("y", PositionY::new());
+
+ simulation.step();
+
+ let mut smallest = [f64::INFINITY, f64::INFINITY];
+ let mut largest = [f64::NEG_INFINITY, f64::NEG_INFINITY];
+
+ for node in simulation.positions() {
+ if node[0] < smallest[0] {
+ smallest[0] = node[0];
+ }
+ if node[1] < smallest[1] {
+ smallest[1] = node[1];
+ }
+ if node[0] > largest[0] {
+ largest[0] = node[0];
+ }
+ if node[1] > largest[1] {
+ largest[1] = node[1];
+ }
+ }
+
+ (smallest, largest, simulation)
+}
+
+#[derive(Default)]
+struct Elements {
+ ids: Vec<String>,
+
+ connections: Vec<Line>,
+ nodes: Vec<Anchor>,
+ titles: Vec<Text>,
+
+ style: String,
+}
+
+fn build_svg_elements<N: GraphNode>(center: &N, nodes: &[N], simulation: &Simulation) -> Elements {
+ let mut elements = Elements::default();
+
+ for (i, pos) in simulation.positions().enumerate() {
+ let note_id = hash(format!("{}-{}", pos[0], pos[1]));
+
+ if pos != [0.0, 0.0] {
+ let id = hash(format!("{}-{}-{}-{}", 0.0, 0.0, pos[0], pos[1]));
+
+ write!(
+ &mut elements.style,
+ ".note--{note_id}:hover ~ .note-connection--{note_id} {{}}"
+ )
+ .expect("failed to write to string");
+
+ elements.connections.push(
+ Line::new()
+ .set("id", id)
+ .set(
+ "class",
+ format!("note-connection note-connection--{note_id}"),
+ )
+ .set("x1", 0.0)
+ .set("y1", 0.0)
+ .set("x2", pos[0])
+ .set("y2", pos[1])
+ .set("stroke", "gray"),
+ );
+
+ elements.ids.push(note_id.clone());
+ }
+
+ let note = if i == 0 { center } else { &nodes[i - 1] };
+
+ elements.nodes.push(
+ Anchor::new()
+ .set("class", format!("note note--{note_id}"))
+ .set("href", note.link())
+ .add(
+ Circle::new()
+ .set("class", "note-node")
+ .set("r", 1.5)
+ .set("cx", pos[0])
+ .set("cy", pos[1])
+ .set("fill", "black"),
+ ),
+ );
+
+ write!(
+ &mut elements.style,
+ ".note--{note_id}:hover ~ .note-text--{note_id} {{opacity:100}}"
+ )
+ .expect("failed to write to string");
+
+ elements.titles.push(
+ Text::new(note.name())
+ .set("class", format!("note-text note-text--{note_id}"))
+ .set("x", pos[0] + 2.0)
+ .set("y", pos[1] + 0.65),
+ );
+ }
+
+ elements
+}
+
+#[allow(clippy::needless_pass_by_value)]
+fn hash(s: String) -> String {
+ let mut hasher = rustc_hash::FxHasher::default();
+
+ hasher.write(s.as_bytes());
+ hasher.write_usize(s.len());
+
+ data_encoding::BASE64URL_NOPAD.encode(&hasher.finish().to_le_bytes())
+}
+
+#[cfg(test)]
+mod test_render_node_graph {
+ use super::*;
+
+ struct NodeImpl;
+
+ impl GraphNode for NodeImpl {
+ fn name(&self) -> String {
+ "test".to_string()
+ }
+
+ fn link(&self) -> String {
+ String::new()
+ }
+ }
+
+ #[test]
+ #[ignore]
+ fn simple() {
+ let rendered = render_node_graph(&NodeImpl, &[NodeImpl, NodeImpl, NodeImpl]);
+
+ panic!("{rendered}");
+ }
+}
diff --git a/sable-renderer/src/templates/funcs.rs b/sable-renderer/src/templates/funcs.rs
new file mode 100644
index 0000000..0c0d7ec
+#![allow(clippy::items_after_test_module)]
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub(super) struct Heading {
+ pub level: usize,
+ pub title: String,
+ pub children: Vec<Self>,
+}
+
+pub(super) fn extract_headings(contents: &str) -> Vec<Heading> {
+ let mut headings = Vec::new();
+ let mut in_code_block = false;
+
+ for line in contents.lines() {
+ if line.trim_start().starts_with("```") {
+ in_code_block = !in_code_block;
+ continue;
+ }
+
+ if in_code_block {
+ continue;
+ }
+
+ if line.starts_with('#') {
+ let level = line.chars().take_while(|&c| c == '#').count();
+ let title = line[level..].trim().to_string();
+
+ headings.push(Heading {
+ level,
+ title,
+ children: vec![],
+ });
+ }
+ }
+
+ headings
+}
+
+#[cfg(test)]
+mod test_extract_headings {
+ use super::extract_headings;
+
+ #[test]
+ fn test_() {}
+}
diff --git a/sable-renderer/src/templates/markdown.rs b/sable-renderer/src/templates/markdown.rs
new file mode 100644
index 0000000..983382e
+use std::collections::HashMap;
+
+use sable_markdown::render::UrlRewriter;
+use sable_vault::{SharedVault, Vault};
+use tera::Filter;
+
+use crate::templates::FilterError;
+
+pub(super) struct Markdown {
+ vault: SharedVault,
+}
+
+impl Markdown {
+ pub(super) const fn new(vault: SharedVault) -> Self {
+ Self { vault }
+ }
+}
+
+impl Filter for Markdown {
+ fn filter(
+ &self,
+ value: &tera::Value,
+ _args: &HashMap<String, tera::Value>,
+ ) -> tera::Result<tera::Value> {
+ let tera::Value::String(raw) = value else {
+ return Err(tera::Error::call_filter(
+ "markdown",
+ FilterError::new("filter only accepts string as an imput"),
+ ));
+ };
+
+ let root = match sable_markdown::parser::parse_markdown(raw) {
+ Ok(root) => root,
+ Err(err) => {
+ return Err(tera::Error::call_filter("markdown", FilterError::new(err)));
+ }
+ };
+
+ let vault = self.vault.read();
+
+ let config = sable_markdown::render::Config::default()
+ .with_link_rewriter(LinkRewriter { _vault: &vault })
+ .with_wikilink_rewriter(WikiLinkRewriter { vault: &vault });
+
+ let rendered = sable_markdown::render::render_html(&root, &config);
+
+ drop(config);
+ drop(vault);
+
+ Ok(tera::Value::String(rendered))
+ }
+}
+
+struct LinkRewriter<'v> {
+ _vault: &'v Vault,
+}
+
+impl UrlRewriter for LinkRewriter<'_> {
+ fn rewrite(&self, path: &str, _target: Option<&str>) -> String {
+ if path.starts_with("ftp") || path.starts_with("http") || path.starts_with("mailto") {
+ return path.to_string();
+ }
+
+ crate::slugify_path(path)
+ }
+}
+
+struct WikiLinkRewriter<'v> {
+ vault: &'v Vault,
+}
+
+impl UrlRewriter for WikiLinkRewriter<'_> {
+ fn rewrite(&self, path: &str, target: Option<&str>) -> String {
+ if path.starts_with("ftp") || path.starts_with("http") || path.starts_with("mailto") {
+ return path.to_string();
+ }
+
+ let Some((path, _note)) = self
+ .vault
+ .find_note_by_title(path)
+ .or_else(|| self.vault.find_note_by_name(path))
+ else {
+ tracing::warn!(path=?path, target=?target, "unknown wikilink note");
+
+ return path.to_string();
+ };
+
+ let path = path.slug.with_extension("html");
+
+ format!("/{}", url_escape::encode_path(path.as_str()))
+ }
+}
diff --git a/sable-renderer/src/templates/mod.rs b/sable-renderer/src/templates/mod.rs
new file mode 100644
index 0000000..41d7d3e
+mod func_link_graph;
+mod funcs;
+mod markdown;
+
+use sable_core::config::Config;
+use sable_vault::SharedVault;
+use tera::{Context, Tera};
+
+static DEFAULT_TEMPLATE_NAME: &str = "--sable-default.html";
+static DEFAULT_TEMPLATE: &str = include_str!("default.html");
+
+#[derive(Debug, miette::Diagnostic, thiserror::Error)]
+pub enum TemplatesError {
+ #[error("failed to convert path as its not valid utf-8")]
+ PathNotUtf8,
+ #[error("failed to load tera templates")]
+ TeraInit(#[source] tera::Error),
+ #[error("failed to render template")]
+ RenderTemplate(#[source] tera::Error),
+}
+
+/// A wrapper around [`Tera`] that provides the built-in template and custom filters/functions.
+#[derive(Debug)]
+pub struct Templates {
+ tera: Tera,
+
+ default_template: Option<String>,
+}
+
+impl Templates {
+ pub fn load(config: &Config, vault: SharedVault) -> Result<Self, TemplatesError> {
+ let dir_str = config
+ .templates
+ .to_str()
+ .ok_or(TemplatesError::PathNotUtf8)?;
+ let dir_pattern = format!("{dir_str}/**/*");
+
+ let mut tera = Tera::new(&dir_pattern).map_err(TemplatesError::TeraInit)?;
+
+ tera.add_raw_template(DEFAULT_TEMPLATE_NAME, DEFAULT_TEMPLATE)
+ .expect("failed to add default fallback template");
+
+ tera.register_filter("markdown", markdown::Markdown::new(vault.clone()));
+
+ tera.register_function("link_graph", func_link_graph::LinkGraph::new(vault));
+
+ for template in tera.get_template_names() {
+ tracing::debug!(template=%template, "loaded tera template");
+ }
+
+ Ok(Self {
+ tera,
+
+ default_template: config.default_template.clone(),
+ })
+ }
+
+ // pub fn reload(&mut self) {
+ // self.tera.full_reload();
+ // }
+
+ /// Returns an iterator over the names of all registered templates in an unspecified order.
+ pub fn get_template_names(&self) -> impl Iterator<Item = &str> {
+ self.tera.get_template_names()
+ }
+
+ /// Checks if the given template is loaded.
+ #[must_use]
+ pub fn has_template(&self, name: &str) -> bool {
+ self.get_template_names().any(|tn| tn == name)
+ }
+
+ pub fn render(&self, template_name: &str, context: &Context) -> Result<String, TemplatesError> {
+ let name = if self.has_template(template_name) {
+ template_name
+ } else if let Some(default_template) = &self.default_template {
+ default_template
+ } else {
+ tracing::warn!(template=%template_name, "requested template does not exist, using fallback");
+
+ DEFAULT_TEMPLATE_NAME
+ };
+
+ self.tera
+ .render(name, context)
+ .map_err(TemplatesError::RenderTemplate)
+ }
+}
+
+#[derive(Debug)]
+struct FilterError {
+ msg: String,
+}
+
+impl FilterError {
+ fn new<M: ToString>(msg: M) -> Self {
+ Self {
+ msg: msg.to_string(),
+ }
+ }
+}
+
+impl std::fmt::Display for FilterError {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ write!(f, "{}", self.msg)
+ }
+}
+
+impl std::error::Error for FilterError {}
diff --git a/sable-vault/Cargo.toml b/sable-vault/Cargo.toml
new file mode 100644
index 0000000..4acef57
+[package]
+name = "sable-vault"
+version = "0.1.0"
+edition = "2024"
+
+workspace = ".."
+
+[features]
+default = ["git"]
+
+git = ["dep:which"]
+
+base = ["dep:nom"]
+canvas = []
+
+[dependencies]
+sable-frontmatter = { path = "../sable-frontmatter" }
+
+camino.workspace = true
+convert_case.workspace = true
+jiff.workspace = true
+miette.workspace = true
+parking_lot.workspace = true
+petgraph.workspace = true
+regex.workspace = true
+serde.workspace = true
+serde_json.workspace = true
+slug.workspace = true
+thiserror.workspace = true
+tracing.workspace = true
+walkdir.workspace = true
+
+nom = { workspace = true, optional = true }
+which = { workspace = true, optional = true }
diff --git a/sable-vault/README.md b/sable-vault/README.md
new file mode 100644
index 0000000..e542e41
+# sable-vault
+
+a read-only structured view of an obsidian vault.
diff --git a/sable-vault/src/base.rs b/sable-vault/src/base.rs
new file mode 100644
index 0000000..e69de29
diff --git a/sable-vault/src/canvas.rs b/sable-vault/src/canvas.rs
new file mode 100644
index 0000000..3684d08
+use std::collections::HashMap;
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
+pub enum PresetColor {
+ #[serde(rename = "1")]
+ Red = 1,
+ #[serde(rename = "2")]
+ Orange = 2,
+ #[serde(rename = "3")]
+ Yellow = 3,
+ #[serde(rename = "4")]
+ Green = 4,
+ #[serde(rename = "5")]
+ Cyan = 5,
+ #[serde(rename = "6")]
+ Purple = 6,
+}
+
+#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
+#[serde(untagged)]
+pub enum Color {
+ Preset(PresetColor),
+ Color(HexColor),
+}
+
+#[derive(Debug, Clone, Hash, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
+#[repr(transparent)]
+#[serde(transparent)]
+pub struct EdgeId(pub(self) String);
+
+#[derive(Debug, Clone, Hash, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
+#[repr(transparent)]
+#[serde(transparent)]
+pub struct NodeId(pub(self) String);
+
+#[derive(Debug, Default, serde::Serialize, serde::Deserialize)]
+pub struct JsonCanvas {
+ #[serde(
+ serialize_with = "serialize_as_vec_node",
+ deserialize_with = "deserialize_as_map_node"
+ )]
+ #[serde(skip_serializing_if = "HashMap::is_empty", default)]
+ nodes: HashMap<NodeId, Node>,
+
+ #[serde(
+ serialize_with = "serialize_as_vec_edge",
+ deserialize_with = "deserialize_as_map_edge"
+ )]
+ #[serde(skip_serializing_if = "HashMap::is_empty", default)]
+ edges: HashMap<EdgeId, Edge>,
+}
+
+#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct Edge {
+ pub id: EdgeId,
+
+ #[serde(skip_serializing_if = "Option::is_none")]
+ color: Option<Color>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ label: Option<String>,
+
+ pub from_node: NodeId,
+ pub to_node: NodeId,
+
+ #[serde(skip_serializing_if = "Option::is_none")]
+ from_side: Option<EdgeSide>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ to_side: Option<EdgeSide>,
+
+ #[serde(skip_serializing_if = "Option::is_none")]
+ from_end: Option<EdgeEnd>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ to_end: Option<EdgeEnd>,
+}
+
+#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub enum EdgeSide {
+ Top,
+ Left,
+ Right,
+ Bottom,
+}
+
+#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub enum EdgeEnd {
+ None,
+ Arrow,
+}
diff --git a/sable-vault/src/data.rs b/sable-vault/src/data.rs
new file mode 100644
index 0000000..e69de29
diff --git a/sable-vault/src/file.rs b/sable-vault/src/file.rs
new file mode 100644
index 0000000..73bdba0
+use petgraph::graph::NodeIndex;
+
+use crate::{
+ ItemPathBuf,
+ note::{Note, NoteError},
+};
+
+static AUDIO_EXTS: &[&str] = &["3gp", "flac", "m4a", "mp3", "ogg", "opus", "wav"];
+static DATA_EXTS: &[&str] = &["json", "toml", "yaml"];
+static IMAGE_EXTS: &[&str] = &["bmp", "gif", "png", "jpeg", "jpg", "svg", "webp"];
+static VIDEO_EXTS: &[&str] = &["avi", "mkv", "mov", "mp4", "ogv", "webm"];
+
+static BASE_EXTS: &[&str] = &["base"];
+static CANVAS_EXTS: &[&str] = &["canvas"];
+static NOTE_EXTS: &[&str] = &["adoc", "gem", "md", "org"];
+
+static HTML_EXTS: &[&str] = &["html", "htm"];
+
+/// All the supported types of files that can exist in a Vault.
+#[derive(Debug, Clone, Hash, PartialEq, Eq)]
+pub enum FileKind {
+ /// An audio file.
+ ///
+ /// Supported file extensions: `3gp`, `flac`, `m4a`, `mp3`, `ogg`, `opus`, `wav`.
+ Audio,
+ /// A generic data file.
+ ///
+ /// Supported file extensions: `json`, `toml`, `yaml`.
+ Data,
+ /// An image file.
+ ///
+ /// Supported file extensions: `bmp`, `gif`, `png`, `jpeg`, `jpg`, `svg`, `webp`.
+ Image,
+ /// A video file.
+ ///
+ /// Supported file extensions: `avi`, `mkv`, `mov`, `mp4`, `ogv`, `webm`.
+ Video,
+
+ /// A Obsidian Base.
+ Base,
+ /// A Obsidian Canvas.
+ Canvas,
+ /// A Obsidian Note.
+ Note,
+
+ /// An HTML file.
+ ///
+ /// Supported file extensions: `html`, `htm`.
+ Html,
+
+ /// An unknown (unsupport) file type.
+ Unknown(Option<String>),
+}
+
+impl FileKind {
+ /// Returns `true` if the file kind is [`FileKind::Audio`], [`FileKind::Image`], or [`FileKind::Video`].
+ #[must_use]
+ pub const fn is_asset(&self) -> bool {
+ self.is_audio() || self.is_image() || self.is_video()
+ }
+
+ /// Returns `true` if the file kind is [`FileKind::Audio`].
+ #[must_use]
+ pub const fn is_audio(&self) -> bool {
+ matches!(self, Self::Audio)
+ }
+
+ /// Returns `true` if the file kind is [`FileKind::Data`].
+ #[must_use]
+ pub const fn is_data(&self) -> bool {
+ matches!(self, Self::Data)
+ }
+
+ /// Returns `true` if the file kind is [`FileKind::Image`].
+ #[must_use]
+ pub const fn is_image(&self) -> bool {
+ matches!(self, Self::Image)
+ }
+
+ /// Returns `true` if the file kind is [`FileKind::Video`].
+ #[must_use]
+ pub const fn is_video(&self) -> bool {
+ matches!(self, Self::Video)
+ }
+
+ /// Returns `true` if the file kind is [`FileKind::Base`].
+ #[must_use]
+ pub const fn is_base(&self) -> bool {
+ matches!(self, Self::Base)
+ }
+
+ /// Returns `true` if the file kind is [`FileKind::Canvas`].
+ #[must_use]
+ pub const fn is_canvas(&self) -> bool {
+ matches!(self, Self::Canvas)
+ }
+
+ /// Returns `true` if the file kind is [`FileKind::Note`].
+ #[must_use]
+ pub const fn is_note(&self) -> bool {
+ matches!(self, Self::Note)
+ }
+
+ /// Returns `true` if the file kind is [`FileKind::Html`].
+ #[must_use]
+ pub const fn is_html(&self) -> bool {
+ matches!(self, Self::Html)
+ }
+}
+
+/// A Vault file.
+///
+/// This is the base entry of all files in a Vault.
+/// Bases, Canvases, and Note are all converted from this.
+#[derive(Debug, Clone, Hash, PartialEq, Eq)]
+pub struct File {
+ /// The path of the file.
+ pub path: ItemPathBuf,
+
+ /// The kind of file this is.
+ ///
+ /// [`FileKind::Base`], [`FileKind::Canvas`], and [`FileKind::Note`] are all converted to their respected structs.
+ /// As such any accessable file will never have those as a kind.
+ pub kind: FileKind,
+}
+
+impl File {
+ /// Create a new [`File`], getting the [`FileKind`] from the path extension.
+ #[must_use]
+ pub fn from_path(path: ItemPathBuf) -> Self {
+ let kind = path
+ .extension()
+ .map_or(FileKind::Unknown(None), |ext| match ext {
+ _ if AUDIO_EXTS.contains(&ext) => FileKind::Audio,
+ _ if DATA_EXTS.contains(&ext) => FileKind::Data,
+ _ if IMAGE_EXTS.contains(&ext) => FileKind::Image,
+ _ if VIDEO_EXTS.contains(&ext) => FileKind::Video,
+
+ _ if BASE_EXTS.contains(&ext) => FileKind::Base,
+ _ if CANVAS_EXTS.contains(&ext) => FileKind::Canvas,
+ _ if NOTE_EXTS.contains(&ext) => FileKind::Note,
+
+ _ if HTML_EXTS.contains(&ext) => FileKind::Html,
+
+ _ => FileKind::Unknown(Some(ext.to_string())),
+ });
+
+ if matches!(&kind, FileKind::Unknown(_)) {
+ tracing::warn!(path=%path.relative, "unknown file type");
+ }
+
+ Self { path, kind }
+ }
+
+ /// Convert this file referencce into a [`Note`].
+ ///
+ /// # Errors
+ ///
+ /// This will error if it failed to read the note from the file system,
+ /// its unable to deserialize the frontmatter,
+ /// or it was unable to get file system metadata of the note.
+ pub fn into_note(self, index: NodeIndex) -> Result<Note, NoteError> {
+ Note::from_path(self.path, index)
+ }
+}
diff --git a/sable-vault/src/lib.rs b/sable-vault/src/lib.rs
new file mode 100644
index 0000000..da8a0eb
+#![deny(rust_2018_idioms, unsafe_code)]
+#![warn(
+ absolute_paths_not_starting_with_crate,
+ ambiguous_associated_items,
+ anonymous_parameters,
+ arithmetic_overflow,
+ array_into_iter,
+ asm_sub_register,
+ bad_asm_style,
+ bindings_with_variant_name,
+ break_with_label_and_loop,
+ clashing_extern_declarations,
+ coherence_leak_check,
+ conflicting_repr_hints,
+ confusable_idents,
+ const_evaluatable_unchecked,
+ const_item_mutation,
+ dangling_pointers_from_temporaries,
+ dead_code,
+ deprecated_in_future,
+ deprecated_where_clause_location,
+ deprecated,
+ deref_into_dyn_supertrait,
+ deref_nullptr,
+ drop_bounds,
+ duplicate_macro_attributes,
+ dyn_drop,
+ ellipsis_inclusive_range_patterns,
+ enum_intrinsics_non_enums,
+ explicit_outlives_requirements,
+ exported_private_dependencies,
+ forbidden_lint_groups,
+ function_item_references,
+ future_incompatible,
+ ill_formed_attribute_input,
+ improper_ctypes_definitions,
+ improper_ctypes,
+ incomplete_features,
+ incomplete_include,
+ ineffective_unstable_trait_impl,
+ inline_no_sanitize,
+ invalid_atomic_ordering,
+ invalid_doc_attributes,
+ invalid_type_param_default,
+ invalid_value,
+ irrefutable_let_patterns,
+ keyword_idents,
+ large_assignments,
+ late_bound_lifetime_arguments,
+ legacy_derive_helpers,
+ macro_expanded_macro_exports_accessed_by_absolute_paths,
+ meta_variable_misuse,
+ missing_abi,
+ missing_copy_implementations,
+ missing_debug_implementations,
+ missing_docs,
+ mixed_script_confusables,
+ mutable_transmutes,
+ named_arguments_used_positionally,
+ named_asm_labels,
+ no_mangle_const_items,
+ no_mangle_generic_items,
+ non_ascii_idents,
+ non_camel_case_types,
+ non_fmt_panics,
+ non_shorthand_field_patterns,
+ non_snake_case,
+ non_upper_case_globals,
+ nonstandard_style,
+ noop_method_call,
+ overflowing_literals,
+ overlapping_range_endpoints,
+ path_statements,
+ patterns_in_fns_without_body,
+ proc_macro_derive_resolution_fallback,
+ pub_use_of_private_extern_crate,
+ redundant_semicolons,
+ repr_transparent_external_private_fields,
+ rust_2021_incompatible_closure_captures,
+ rust_2021_incompatible_or_patterns,
+ rust_2021_prefixes_incompatible_syntax,
+ rust_2021_prelude_collisions,
+ semicolon_in_expressions_from_macros,
+ soft_unstable,
+ stable_features,
+ text_direction_codepoint_in_comment,
+ text_direction_codepoint_in_literal,
+ trivial_bounds,
+ trivial_casts,
+ trivial_numeric_casts,
+ type_alias_bounds,
+ tyvar_behind_raw_pointer,
+ uncommon_codepoints,
+ unconditional_panic,
+ unconditional_recursion,
+ unexpected_cfgs,
+ uninhabited_static,
+ unknown_crate_types,
+ unnameable_test_items,
+ unreachable_code,
+ unreachable_patterns,
+ unsafe_op_in_unsafe_fn,
+ unstable_features,
+ unstable_name_collisions,
+ unused_allocation,
+ unused_assignments,
+ unused_attributes,
+ unused_braces,
+ unused_comparisons,
+ unused_crate_dependencies,
+ unused_doc_comments,
+ unused_extern_crates,
+ unused_features,
+ unused_import_braces,
+ unused_imports,
+ unused_labels,
+ unused_lifetimes,
+ unused_macro_rules,
+ unused_macros,
+ unused_must_use,
+ unused_mut,
+ unused_parens,
+ unused_qualifications,
+ unused_unsafe,
+ unused_variables,
+ useless_deprecated,
+ while_true
+)]
+#![warn(
+ clippy::all,
+ clippy::await_holding_lock,
+ clippy::char_lit_as_u8,
+ clippy::checked_conversions,
+ clippy::cognitive_complexity,
+ clippy::dbg_macro,
+ clippy::debug_assert_with_mut_call,
+ clippy::disallowed_script_idents,
+ clippy::doc_link_with_quotes,
+ clippy::doc_markdown,
+ clippy::empty_enum,
+ clippy::empty_line_after_outer_attr,
+ clippy::empty_structs_with_brackets,
+ clippy::enum_glob_use,
+ clippy::equatable_if_let,
+ clippy::exit,
+ clippy::expl_impl_clone_on_copy,
+ clippy::explicit_deref_methods,
+ clippy::explicit_into_iter_loop,
+ clippy::fallible_impl_from,
+ clippy::filter_map_next,
+ clippy::flat_map_option,
+ clippy::float_cmp_const,
+ clippy::float_cmp,
+ clippy::float_equality_without_abs,
+ clippy::fn_params_excessive_bools,
+ clippy::fn_to_numeric_cast_any,
+ clippy::from_iter_instead_of_collect,
+ clippy::if_let_mutex,
+ clippy::implicit_clone,
+ clippy::imprecise_flops,
+ clippy::index_refutable_slice,
+ clippy::inefficient_to_string,
+ clippy::invalid_upcast_comparisons,
+ clippy::iter_not_returning_iterator,
+ clippy::large_digit_groups,
+ clippy::large_stack_arrays,
+ clippy::large_types_passed_by_value,
+ clippy::let_unit_value,
+ clippy::linkedlist,
+ clippy::lossy_float_literal,
+ clippy::macro_use_imports,
+ clippy::manual_ok_or,
+ clippy::map_err_ignore,
+ clippy::map_flatten,
+ clippy::map_unwrap_or,
+ clippy::match_same_arms,
+ clippy::match_wild_err_arm,
+ clippy::match_wildcard_for_single_variants,
+ clippy::mem_forget,
+ clippy::missing_const_for_fn,
+ clippy::missing_enforced_import_renames,
+ clippy::missing_errors_doc,
+ clippy::missing_panics_doc,
+ clippy::mut_mut,
+ clippy::mutex_integer,
+ clippy::needless_borrow,
+ clippy::needless_continue,
+ clippy::needless_for_each,
+ clippy::needless_pass_by_value,
+ clippy::negative_feature_names,
+ clippy::nonstandard_macro_braces,
+ clippy::nursery,
+ clippy::option_if_let_else,
+ clippy::option_option,
+ clippy::path_buf_push_overwrite,
+ clippy::pedantic,
+ clippy::print_stderr,
+ clippy::print_stdout,
+ clippy::ptr_as_ptr,
+ clippy::rc_mutex,
+ clippy::ref_option_ref,
+ clippy::rest_pat_in_fully_bound_structs,
+ clippy::same_functions_in_if_condition,
+ clippy::semicolon_if_nothing_returned,
+ clippy::shadow_unrelated,
+ clippy::similar_names,
+ clippy::single_match_else,
+ clippy::string_add_assign,
+ clippy::string_add,
+ clippy::string_lit_as_bytes,
+ clippy::suspicious_operation_groupings,
+ clippy::todo,
+ clippy::trailing_empty_array,
+ clippy::trait_duplication_in_bounds,
+ clippy::trivially_copy_pass_by_ref,
+ clippy::unimplemented,
+ clippy::unnecessary_wraps,
+ clippy::unnested_or_patterns,
+ clippy::unseparated_literal_suffix,
+ clippy::unused_self,
+ clippy::use_debug,
+ clippy::use_self,
+ clippy::used_underscore_binding,
+ clippy::useless_let_if_seq,
+ clippy::useless_transmute,
+ clippy::verbose_file_reads,
+ clippy::wildcard_dependencies,
+ clippy::wildcard_imports,
+ clippy::zero_sized_map_values
+)]
+
+//! A read only view of an Obsidian vault.
+
+mod utils;
+
+#[cfg(feature = "base")]
+mod base;
+#[cfg(feature = "canvas")]
+mod canvas;
+mod data;
+mod file;
+mod link;
+mod note;
+mod vault;
+
+use std::fmt;
+
+use camino::{Utf8Component, Utf8Path, Utf8PathBuf};
+
+pub use crate::{
+ file::{File, FileKind},
+ link::{Link, LinkKind},
+ note::{Note, NoteContext, NoteContextOwned, NoteError, NoteProperties, extract_tags},
+ vault::{SharedVault, Vault, VaultError},
+};
+
+/// The path to the root of a Vault.
+#[derive(Debug, Clone, Hash, PartialEq, Eq, serde::Serialize)]
+pub struct VaultPath<'p>(pub &'p Utf8Path);
+
+/// The path to the root of a Vault.
+#[derive(Debug, Clone, Hash, PartialEq, Eq, serde::Deserialize)]
+pub struct VaultPathBuf(pub Utf8PathBuf);
+
+impl VaultPathBuf {
+ /// Create a path to a Vault file.
+ #[must_use]
+ pub fn as_item(&self, full: &Utf8Path, relative: &Utf8Path) -> ItemPathBuf {
+ ItemPathBuf::new(self, full, relative)
+ }
+
+ /// Convert into a path reference.
+ #[must_use]
+ pub fn as_ref(&self) -> VaultPath<'_> {
+ VaultPath(self.0.as_path())
+ }
+}
+
+/// The path to a Vault file.
+#[derive(Debug, Clone, Hash, PartialEq, Eq, serde::Serialize)]
+pub struct ItemPath<'p> {
+ /// The root path of the Vault this file belongs to.
+ pub vault: VaultPath<'p>,
+
+ /// The canonical path of this file.
+ pub full: &'p Utf8Path,
+ /// The path of this file relative to the root of the Vault.
+ pub relative: &'p Utf8Path,
+
+ /// The relative path converted to a URL safe slug.
+ pub slug: &'p Utf8Path,
+}
+
+/// The path to a Vault file.
+#[derive(Debug, Clone, Hash, PartialEq, Eq, serde::Deserialize)]
+pub struct ItemPathBuf {
+ /// The root path of the Vault this file belongs to.
+ pub vault: VaultPathBuf,
+
+ /// The canonical (file system) path of this file.
+ pub full: Utf8PathBuf,
+ /// The path of this file relative to the root of the Vault.
+ pub relative: Utf8PathBuf,
+
+ /// The [`ItemPathBuf::relative`] path converted to a URL safe slug (excluding extension).
+ pub slug: Utf8PathBuf,
+}
+
+impl ItemPathBuf {
+ /// Create a new item path
+ ///
+ /// This is mostly for organization as its only used in [`VaultPathBuf::as_item`].
+ #[must_use]
+ pub fn new(vault: &VaultPathBuf, full: &Utf8Path, relative: &Utf8Path) -> Self {
+ Self {
+ vault: vault.clone(),
+
+ full: full.to_path_buf(),
+ relative: relative.to_path_buf(),
+
+ slug: spluify_path(relative),
+ }
+ }
+
+ /// Get the file extension of this file (if it exists).
+ #[must_use]
+ pub fn extension(&self) -> Option<&str> {
+ self.full.extension()
+ }
+
+ /// Convert into a path reference.
+ #[must_use]
+ pub fn as_ref(&self) -> ItemPath<'_> {
+ ItemPath {
+ vault: self.vault.as_ref(),
+ full: &self.full,
+ relative: &self.relative,
+ slug: &self.slug,
+ }
+ }
+}
+
+impl fmt::Display for ItemPathBuf {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ self.relative.fmt(f)
+ }
+}
+
+#[track_caller]
+fn spluify_path(path: &Utf8Path) -> Utf8PathBuf {
+ let mut path = path.to_path_buf();
+ path.set_extension("");
+
+ let mut new = Utf8PathBuf::new();
+
+ for component in path.components() {
+ if let Utf8Component::Normal(normal) = component {
+ new.push(slug::slugify(normal));
+ }
+ }
+
+ new
+}
diff --git a/sable-vault/src/link.rs b/sable-vault/src/link.rs
new file mode 100644
index 0000000..ce0b794
+use std::{ops::Range, sync::LazyLock};
+
+use regex::Regex;
+
+static LINK_REGEX: LazyLock<Regex> =
+ LazyLock::new(|| Regex::new(r"(?:\[(?P<text>.*?)\])\((?P<link>.*?)\)").unwrap());
+static WIKI_REGEX: LazyLock<Regex> =
+ LazyLock::new(|| Regex::new(r"\[\[(?P<link>.+?)(\|(?P<text>.+))?\]\]").unwrap());
+
+/// The kinds a link can be.
+///
+/// This allows differentiating between Markdown's inline link (for both internal and external links)
+/// and Obsidian's Wikilinks.
+#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq)]
+pub enum LinkKind {
+ /// A Markdown link that points to a 'internal' Vault file.
+ Internal,
+ /// A Markdown link that points to a 'external' website outside the Vault.
+ External,
+ /// A Obsidian Wikilink.
+ ///
+ /// Can only be [`LinkKind::Internal`].
+ Wiki,
+}
+
+/// The details of a link inside a Vault note.
+#[derive(Debug, Clone, Hash, PartialEq, Eq)]
+pub struct Link {
+ /// The kind of link this is.
+ pub kind: LinkKind,
+ /// The 'target' of the link.
+ ///
+ /// For Markdown links this is the URL,
+ /// and for Obsidian Wikilinks this is the note title.
+ pub href: String,
+ /// The display text of the link.
+ ///
+ /// For Markdown links this is the name,
+ /// and for Obsidian Wikilinks this is the `|` name.
+ pub title: Option<String>,
+ /// The byte position of the *whole* link.
+ pub pos: Range<usize>,
+}
+
+impl Link {
+ /// Extract any links from a Vault note's content.
+ pub fn collect(s: &str) -> Vec<Self> {
+ let mut tags = Vec::new();
+
+ for captures in LINK_REGEX.captures_iter(s) {
+ let Some(full) = captures.get(0) else {
+ continue;
+ };
+ let Some(text) = captures.get(1) else {
+ continue;
+ };
+ let Some(url) = captures.get(2) else {
+ continue;
+ };
+
+ tags.push(Self {
+ kind: LinkKind::External,
+ href: url.as_str().to_string(),
+ title: Some(text.as_str().to_string()),
+ pos: full.range(),
+ });
+ }
+
+ for captures in WIKI_REGEX.captures_iter(s) {
+ let Some(full) = captures.get(0) else {
+ continue;
+ };
+ let Some(capture) = captures.get(1) else {
+ continue;
+ };
+ let title = captures.get(3).map(|c| c.as_str().to_string());
+
+ tags.push(Self {
+ kind: LinkKind::Wiki,
+ href: capture.as_str().to_string(),
+ title,
+ pos: full.range(),
+ });
+ }
+
+ tags
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_external() {
+ let text = "[test](https://example.com)";
+
+ let expected = &[Link {
+ kind: LinkKind::External,
+ href: "https://example.com".to_string(),
+ title: Some("test".to_string()),
+ pos: 0..27,
+ }];
+ let got = Link::collect(text);
+
+ assert_eq!(expected, &got[..]);
+ }
+
+ #[test]
+ fn test_wiki_no_title() {
+ let text = "[[test]]";
+
+ let expected = &[Link {
+ kind: LinkKind::Wiki,
+ href: "test".to_string(),
+ title: None,
+ pos: 0..8,
+ }];
+ let got = Link::collect(text);
+
+ assert_eq!(expected, &got[..]);
+ }
+
+ #[test]
+ fn test_wiki_with_title() {
+ let text = "[[test|title]]";
+
+ let expected = &[Link {
+ kind: LinkKind::Wiki,
+ href: "test".to_string(),
+ title: Some("title".to_string()),
+ pos: 0..14,
+ }];
+ let got = Link::collect(text);
+
+ assert_eq!(expected, &got[..]);
+ }
+
+ #[test]
+ fn test_fail_invalid_1() {
+ let text = "[[test]";
+
+ let got = Link::collect(text);
+
+ assert!(got.is_empty());
+ }
+
+ #[test]
+ fn test_fail_invalid_3() {
+ let text = "[test]]";
+
+ let got = Link::collect(text);
+
+ assert!(got.is_empty());
+ }
+
+ #[test]
+ fn test_fail_invalid_4() {
+ let text = "[[test|title]";
+
+ let got = Link::collect(text);
+
+ assert!(got.is_empty());
+ }
+
+ #[test]
+ fn test_fail_invalid_2() {
+ let text = "[test|title]]";
+
+ let got = Link::collect(text);
+
+ assert!(got.is_empty());
+ }
+}
diff --git a/sable-vault/src/note.rs b/sable-vault/src/note.rs
new file mode 100644
index 0000000..45fccbd
+use std::{collections::BTreeMap, sync::LazyLock};
+
+use camino::Utf8PathBuf;
+use convert_case::{Case, Casing as _};
+use petgraph::graph::NodeIndex;
+use regex::Regex;
+use serde::Deserialize as _;
+
+use crate::{
+ ItemPath, ItemPathBuf,
+ link::Link,
+ utils::{FileMetadata, Heading, extract_headings, make_table_of_contents},
+};
+
+/// Errors that could occur while parsing an Obsidian [`Note`].
+#[allow(missing_docs)]
+#[derive(Debug, miette::Diagnostic, thiserror::Error)]
+pub enum NoteError {
+ #[error("failed to read frontmatter of note `{0}`")]
+ FrontmatterParse(Utf8PathBuf, #[source] sable_frontmatter::MetadataError),
+ #[error("failed to deserialize recognized obsidian properties of note `{0}`")]
+ PropertiesParse(Utf8PathBuf, #[source] serde_json::Error),
+ #[error("failed to read note at `{0}`")]
+ IoRead(Utf8PathBuf, #[source] std::io::Error),
+ #[error("failed to get metadata of note `{0}`")]
+ GetMetadata(Utf8PathBuf, #[source] crate::utils::Error),
+}
+
+/// Recognized [`Note`] frontmatter properties.
+#[allow(missing_docs)]
+#[derive(Debug, Clone, Default, Hash, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
+pub struct NoteProperties {
+ pub aliases: Option<Vec<String>>,
+ pub css_classes: Option<Vec<String>>,
+ pub tags: Option<Vec<String>>,
+
+ pub template: Option<String>,
+ pub title: Option<String>,
+
+ pub draft: Option<bool>,
+
+ #[serde(flatten)]
+ other: BTreeMap<String, sable_frontmatter::Metadata>,
+}
+
+impl NoteProperties {
+ pub fn get(&self, key: &str) -> Option<&sable_frontmatter::Metadata> {
+ self.other.get(key)
+ }
+}
+
+/// Predescribed [`Note`] context for [`tera`] templates or related engines.
+///
+/// [`tera`]: https://docs.rs/tera/
+#[derive(Debug, serde::Serialize)]
+pub struct NoteContext<'n> {
+ /// The path of the [`Note`].
+ pub path: ItemPath<'n>,
+ /// The graph index of the [`Note`].
+ pub index: NodeIndex,
+
+ /// The filename of the [`Note`].
+ pub name: &'n str,
+ /// The title of the [`Note`].
+ pub title: &'n str,
+
+ /// The file metadata of the [`Note`].
+ pub metadata: &'n FileMetadata,
+ /// The frontmatter properties of the [`Note`].
+ pub properties: &'n NoteProperties,
+
+ /// Structured table of contents of the [`Note`].
+ pub toc: &'n [Heading],
+
+ /// The raw markdown of the [`Note`].
+ pub contents: &'n str,
+}
+
+/// Predescribed [`Note`] context for [`tera`] templates or related engines.
+///
+/// [`tera`]: https://docs.rs/tera/
+#[derive(Debug, serde::Deserialize)]
+pub struct NoteContextOwned {
+ /// The path of the [`Note`].
+ pub path: ItemPathBuf,
+ /// The graph index of the [`Note`].
+ pub index: NodeIndex,
+
+ /// The filename of the [`Note`].
+ pub name: String,
+ /// The title of the [`Note`].
+ pub title: String,
+
+ /// The file metadata of the [`Note`].
+ pub metadata: FileMetadata,
+ /// The frontmatter properties of the [`Note`].
+ pub properties: NoteProperties,
+
+ /// Structured table of contents of the [`Note`].
+ pub toc: Vec<Heading>,
+
+ /// The raw markdown of the [`Note`].
+ pub contents: String,
+}
+
+/// A parsed Obsidian note.
+#[derive(Debug, Clone, Hash, PartialEq, Eq)]
+pub struct Note {
+ /// The path of the [`Note`].
+ pub path: ItemPathBuf,
+ /// The graph index of the [`Note`].
+ pub index: NodeIndex,
+
+ /// The filename of the [`Note`].
+ pub name: String,
+
+ /// The file metadata of the [`Note`].
+ pub metadata: FileMetadata,
+ /// The frontmatter properties of the [`Note`].
+ pub properties: NoteProperties,
+
+ /// Structured table of contents of the [`Note`].
+ pub toc: Vec<Heading>,
+ /// Extracted links from the [`Note`]'s markdown.
+ pub links: Vec<Link>,
+
+ /// The raw markdown of the [`Note`].
+ pub contents: String,
+}
+
+impl Note {
+ /// Parse a Obsidian note from a [`ItemPathBuf`] and the notes raw content.
+ ///
+ /// # Errors
+ ///
+ /// This will error if its unable to deserialize the frontmatter,
+ /// or it was unable to get file system metadata of the note.
+ pub fn from_str(path: ItemPathBuf, index: NodeIndex, raw: &str) -> Result<Self, NoteError> {
+ let (frontmatter, contents) = sable_frontmatter::parse(raw)
+ .map(|(frontmatter, contents)| (frontmatter, contents.to_string()))
+ .map_err(|err| NoteError::FrontmatterParse(path.relative.to_path_buf(), err))?;
+
+ let mut properties = if let Some(frontmatter) = frontmatter.as_ref() {
+ NoteProperties::deserialize(frontmatter)
+ .map_err(|err| NoteError::PropertiesParse(path.relative.to_path_buf(), err))?
+ } else {
+ NoteProperties::default()
+ };
+
+ let metadata = FileMetadata::new(&path.full)
+ .map_err(|err| NoteError::GetMetadata(path.relative.to_path_buf(), err))?;
+
+ let name = path.full.file_stem().map_or_else(
+ || {
+ tracing::warn!(path=%path.relative, "unable to convert note file name into title");
+
+ String::new()
+ },
+ |t| t.to_case(Case::Title),
+ );
+
+ // TODO: use [`Path::file_prefix`] when it stablizes
+ // TODO: check contents for `h1` header to use as the title
+ properties.title.get_or_insert_with(|| name.clone());
+
+ if let Some(mut tags) = extract_tags(contents.as_str()) {
+ properties.tags.get_or_insert_default().append(&mut tags);
+ }
+
+ let headings = extract_headings(&contents);
+ let toc = make_table_of_contents(headings);
+
+ let links = Link::collect(&contents);
+
+ Ok(Self {
+ path,
+ index,
+
+ name,
+
+ metadata,
+ properties,
+
+ toc,
+ links,
+
+ contents,
+ })
+ }
+
+ /// Load and parse a Obsidian note from a [`ItemPathBuf`].
+ ///
+ /// # Errors
+ ///
+ /// This will error if it failed to read the note from the file system,
+ /// its unable to deserialize the frontmatter,
+ /// or it was unable to get file system metadata of the note.
+ pub fn from_path(path: ItemPathBuf, index: NodeIndex) -> Result<Self, NoteError> {
+ let contents = std::fs::read_to_string(&path.full)
+ .map_err(|err| NoteError::IoRead(path.full.clone(), err))?;
+
+ Self::from_str(path, index, &contents)
+ }
+
+ /// Returns the template property of this [`Note`]'s frontmatter.
+ #[must_use]
+ pub fn template(&self) -> Option<&str> {
+ self.properties.template.as_deref()
+ }
+
+ /// Returns the tags property of this [`Note`]'s frontmatter.
+ #[must_use]
+ pub fn tags(&self) -> Option<&[String]> {
+ self.properties.tags.as_deref()
+ }
+
+ /// Returns a reference to the [`Note`]'s path information.
+ #[must_use]
+ pub fn path(&self) -> ItemPath<'_> {
+ self.path.as_ref()
+ }
+
+ /// Returns a reference to the [`Note`]'s filename.
+ #[must_use]
+ pub const fn name(&self) -> &str {
+ self.name.as_str()
+ }
+
+ /// Returns a reference to the [`Note`]'s title.
+ #[must_use]
+ pub fn title(&self) -> &str {
+ self.properties.title.as_deref().unwrap_or_default()
+ }
+
+ /// Create a template engine compatible render context reference.
+ #[must_use]
+ pub fn as_context(&self) -> NoteContext<'_> {
+ NoteContext {
+ path: self.path(),
+ index: self.index,
+
+ name: self.name(),
+ title: self.title(),
+
+ metadata: &self.metadata,
+ properties: &self.properties,
+
+ toc: &self.toc,
+
+ contents: &self.contents,
+ }
+ }
+}
+
+/// Finds Obsidian `#tags`, and converts them into a list without preceding `#`.
+pub fn extract_tags(content: &str) -> Option<Vec<String>> {
+ static REGEX: LazyLock<Regex> = LazyLock::new(|| {
+ Regex::new(r"(?:#([^\s#]+))").expect("failed to construct obsidian tag regex")
+ });
+
+ fn handle(captures: ®ex::Captures<'_>) -> Option<String> {
+ let mut iter = captures.iter();
+
+ let _match = iter.next()??;
+ let group = iter.next()??;
+
+ let rest = group.as_str().to_string();
+
+ Some(rest)
+ }
+
+ if !REGEX.is_match(content.as_ref()) {
+ return None;
+ }
+
+ let captures_list = REGEX.captures_iter(content.as_ref()).collect::<Vec<_>>();
+
+ let mut tags = Vec::with_capacity(captures_list.len());
+
+ for captures in captures_list.into_iter().rev() {
+ if let Some(tag) = handle(&captures) {
+ tags.push(tag);
+ }
+ }
+
+ Some(tags)
+}
+
+// pub fn extract_tags(content: Cow<'_, str>) -> (Cow<'_, str>, Option<Vec<String>>) {
+// static REGEX: LazyLock<Regex> = LazyLock::new(|| {
+// Regex::new(r#"(?:#([^\s#]+))"#).expect("failed to construct obsidian tag regex")
+// });
+
+// fn handle(new: &str, captures: ®ex::Captures<'_>) -> Option<(String, String)> {
+// let mut iter = captures.iter();
+
+// let r#match = iter.next()??;
+// let group = iter.next()??;
+
+// let rest = group.as_str().to_string();
+
+// let span = r#match.range();
+// let updated = [&new[0..span.start], "", &new[span.end..]].concat();
+
+// Some((updated, rest))
+// }
+
+// if !REGEX.is_match(content.as_ref()) {
+// return (content, None);
+// }
+
+// let mut new = content.as_ref().to_string();
+
+// let captures = REGEX.captures_iter(content.as_ref()).collect::<Vec<_>>();
+
+// let mut tags = Vec::with_capacity(captures.len());
+
+// for captures in captures.into_iter().rev() {
+// if let Some((updated, tag)) = handle(&new, &captures) {
+// new = updated;
+// tags.push(tag);
+// }
+// }
+
+// (Cow::from(new), Some(tags))
+// }
diff --git a/sable-vault/src/utils.rs b/sable-vault/src/utils.rs
new file mode 100644
index 0000000..6dfd50d
+use std::fs::File;
+
+use camino::Utf8Path;
+use jiff::Timestamp;
+
+#[cfg(feature = "git")]
+static CREATED: &str = r#"git log --reverse -1 --pretty="format:%cI" --"#;
+#[cfg(feature = "git")]
+static MODIFIED: &str = r#"git log -1 --pretty="format:%cI" --"#;
+
+#[cfg(feature = "git")]
+fn run(arg: &str) -> Result<Option<Timestamp>, Error> {
+ use std::process::Command;
+
+ if which::which("git").is_err() {
+ return Ok(None);
+ }
+
+ let (shell, cmd) = if cfg!(target_os = "windows") {
+ ("cmd", "/C")
+ } else {
+ ("sh", "-s")
+ };
+
+ tracing::debug!(cmd=?arg, "running git command");
+
+ let output = Command::new(shell)
+ .arg(cmd)
+ .arg(arg)
+ .output()
+ .map_err(Error::Command)?;
+
+ if !output.status.success() {
+ return Err(Error::CommandFailed(
+ String::from_utf8_lossy(&output.stdout).to_string(),
+ ));
+ }
+
+ let output = String::from_utf8(output.stdout).map_err(Error::OutputUtf8)?;
+ let output = output.trim();
+
+ // TODO: should this be an error, it catches git the file doesnt have an associated commit
+ if output.is_empty() || output.starts_with("fatal") {
+ return Ok(None);
+ }
+
+ let timestamp = output.parse().map_err(Error::TimestampParse)?;
+
+ Ok(Some(timestamp))
+}
+
+#[derive(Debug, miette::Diagnostic, thiserror::Error)]
+pub enum Error {
+ #[error("failed to open file")]
+ FileOpen(#[source] std::io::Error),
+ #[error("failed to get metadata of file")]
+ FileMetadata(#[source] std::io::Error),
+ #[error("failed to get timestamp of file")]
+ FileTime(#[source] std::io::Error),
+ #[error("failed to convert timestamp from system time of file")]
+ TimestampConvert(#[source] jiff::Error),
+
+ #[cfg(feature = "git")]
+ #[error("failed to run command against file")]
+ Command(#[source] std::io::Error),
+ #[cfg(feature = "git")]
+ #[error("failed to run command against file: {0}")]
+ CommandFailed(String),
+ #[cfg(feature = "git")]
+ #[error("failed to convert command output to utf-8 for file")]
+ OutputUtf8(#[source] std::string::FromUtf8Error),
+ #[cfg(feature = "git")]
+ #[error("failed to parse timestamp of file")]
+ TimestampParse(#[source] jiff::Error),
+}
+
+/// Metadata of an arbitrary Vault file.
+///
+/// Currently only stores the file's creation and modification times.
+#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
+pub struct FileMetadata {
+ /// The date the file was created according to the file system.
+ ///
+ /// This may not be correct.
+ pub created: Timestamp,
+ /// The date the file was modified according to the file system.
+ ///
+ /// This may not be correct.
+ pub modified: Timestamp,
+
+ /// The date the file was created according to Git's commit history.
+ pub git_created: Option<Timestamp>,
+ /// The date the file was created modified to Git's commit history.
+ pub git_modified: Option<Timestamp>,
+}
+
+impl FileMetadata {
+ pub fn new(path: &Utf8Path) -> Result<Self, Error> {
+ let file = File::open(path.as_std_path()).map_err(Error::FileOpen)?;
+ let meta = file.metadata().map_err(Error::FileMetadata)?;
+
+ let created = meta.created().map_err(Error::FileTime)?;
+ let created = Timestamp::try_from(created).map_err(Error::TimestampConvert)?;
+
+ let modified = meta.modified().map_err(Error::FileTime)?;
+ let modified = Timestamp::try_from(modified).map_err(Error::TimestampConvert)?;
+
+ #[cfg(not(feature = "git"))]
+ let git_created = None;
+ #[cfg(not(feature = "git"))]
+ let git_modified = None;
+
+ #[cfg(feature = "git")]
+ let git_created = run(&format!("{CREATED} {path}"))?;
+ #[cfg(feature = "git")]
+ let git_modified = run(&format!("{MODIFIED} {path}"))?;
+
+ Ok(Self {
+ created,
+ modified,
+
+ git_created,
+ git_modified,
+ })
+ }
+}
+
+#[derive(Debug, Clone, Hash, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
+pub struct Heading {
+ pub level: usize,
+ pub id: String,
+ pub title: String,
+ pub children: Vec<Self>,
+}
+
+pub fn extract_headings(contents: &str) -> Vec<Heading> {
+ let mut headings = Vec::new();
+ let mut in_code_block = false;
+
+ for line in contents.lines() {
+ if line.trim_start().starts_with("```") {
+ in_code_block = !in_code_block;
+
+ continue;
+ }
+
+ if in_code_block {
+ continue;
+ }
+
+ if line.starts_with('#') {
+ let level = line.chars().take_while(|&c| c == '#').count();
+ let title = line[level..].trim().to_string();
+
+ headings.push(Heading {
+ level,
+ id: String::new(),
+ title,
+ children: vec![],
+ });
+ }
+ }
+
+ headings
+}
+
+// Header code taken from Zola
+/// Converts the flat temp headings into a nested set of headings
+/// representing the hierarchy
+pub fn make_table_of_contents(headings: Vec<Heading>) -> Vec<Heading> {
+ let mut toc = vec![];
+ for heading in headings {
+ // First heading or we try to insert the current heading in a previous one
+ if toc.is_empty() || !insert_into_parent(toc.iter_mut().last(), &heading) {
+ toc.push(heading);
+ }
+ }
+
+ toc
+}
+
+// Takes a potential (mutable) parent and a heading to try and insert into
+// Returns true when it performed the insertion, false otherwise
+fn insert_into_parent(potential_parent: Option<&mut Heading>, heading: &Heading) -> bool {
+ match potential_parent {
+ None => {
+ // No potential parent to insert into so it needs to be insert higher
+ false
+ }
+ Some(parent) => {
+ if heading.level <= parent.level {
+ // Heading is same level or higher so we don't insert here
+ return false;
+ }
+ if heading.level + 1 == parent.level {
+ // We have a direct child of the parent
+ parent.children.push(heading.clone());
+ return true;
+ }
+ // We need to go deeper
+ if !insert_into_parent(parent.children.iter_mut().last(), heading) {
+ // No, we need to insert it here
+ parent.children.push(heading.clone());
+ }
+ true
+ }
+ }
+}
diff --git a/sable-vault/src/vault.rs b/sable-vault/src/vault.rs
new file mode 100644
index 0000000..247efa9
+use std::{
+ collections::{HashMap, HashSet},
+ sync::Arc,
+};
+
+use camino::Utf8PathBuf;
+use petgraph::{
+ graph::{NodeIndex, UnGraph},
+ visit::EdgeRef as _,
+};
+use walkdir::{DirEntry, WalkDir};
+
+use crate::{
+ ItemPathBuf, VaultPathBuf,
+ file::File,
+ link::LinkKind,
+ note::{Note, NoteError},
+};
+
+/// Errors that could oocur while loading a [`Vault`].
+#[allow(missing_docs)]
+#[derive(Debug, miette::Diagnostic, thiserror::Error)]
+pub enum VaultError {
+ #[error("failed to walk source directory")]
+ Walk(#[source] walkdir::Error),
+ #[error("path `{1}` is not valid utf-8")]
+ PathNotUtf8(#[source] camino::FromPathBufError, std::path::PathBuf),
+ #[error("failed to create relative path")]
+ StripPrefix(#[source] std::path::StripPrefixError),
+ #[error("failed to load note")]
+ Note(#[source] NoteError),
+}
+
+/// Structured view of a Obsidian Vault
+#[derive(Debug)]
+pub struct Vault {
+ /// The path to the root of the Vault.
+ pub path: VaultPathBuf,
+
+ /// The (non Obsidian) files of the Vault.
+ pub files: HashMap<ItemPathBuf, File>,
+
+ /// The parsed notes of the Vault.
+ pub notes: HashMap<ItemPathBuf, Note>,
+
+ /// All the tags used in the Vault and their locations.
+ pub tags: HashMap<String, HashSet<ItemPathBuf>>,
+
+ graph: UnGraph<ItemPathBuf, ()>,
+}
+
+impl Vault {
+ /// Create a [`Vault`] from a [`VaultPathBuf`], reading the vault's contents from the disk.
+ ///
+ /// # Errors
+ ///
+ /// This will fail if its unable to do IO
+ /// or is unable to parse any of the supported Obsidian types.
+ pub fn from_path(path: VaultPathBuf) -> Result<Self, VaultError> {
+ let mut this = Self {
+ path,
+
+ files: HashMap::new(),
+ notes: HashMap::new(),
+
+ tags: HashMap::new(),
+
+ graph: UnGraph::default(),
+ };
+
+ this.reload()?;
+
+ Ok(this)
+ }
+
+ /// Clears the currrent Vault and reloads it from disk.
+ ///
+ /// # Errors
+ ///
+ /// This will fail if its unable to do IO
+ /// or is unable to parse any of the supported Obsidian types.
+ pub fn reload(&mut self) -> Result<(), VaultError> {
+ self.files.clear();
+ self.notes.clear();
+ self.tags.clear();
+ self.graph.clear();
+
+ let obsidian_path = self.path.0.join(".obsidian");
+
+ for entry in WalkDir::new(&self.path.0) {
+ let entry = entry.map_err(VaultError::Walk)?;
+
+ if entry.file_name().to_string_lossy().starts_with('.') {
+ continue;
+ }
+
+ if entry.path().starts_with(&obsidian_path) {
+ continue;
+ }
+
+ if !entry.file_type().is_file() {
+ continue;
+ }
+
+ self.handle_entry(&entry)?;
+ }
+
+ self.map_tags();
+ self.map_links();
+
+ Ok(())
+ }
+
+ fn handle_entry(&mut self, entry: &DirEntry) -> Result<(), VaultError> {
+ let full = entry.path().to_path_buf();
+ let full = Utf8PathBuf::try_from(full.clone())
+ .map_err(|err| VaultError::PathNotUtf8(err, full))?;
+ let relative = full
+ .strip_prefix(&self.path.0)
+ .map_err(VaultError::StripPrefix)?;
+
+ let path = self.path.as_item(&full, relative);
+
+ let file = File::from_path(path);
+
+ tracing::debug!(path=%file.path.full, kind=?file.kind, "found vault file");
+
+ if file.kind.is_note() {
+ let index = self.graph.add_node(file.path.clone());
+
+ let _ = self.notes.insert(
+ file.path.clone(),
+ file.into_note(index).map_err(VaultError::Note)?,
+ );
+ } else {
+ let _ = self.files.insert(file.path.clone(), file);
+ }
+
+ Ok(())
+ }
+
+ fn map_tags(&mut self) {
+ for note in self.notes.values() {
+ let Some(tags) = note.tags() else {
+ continue;
+ };
+
+ for tag in tags {
+ self.tags
+ .entry(tag.clone())
+ .or_default()
+ .insert(note.path.clone());
+ }
+ }
+ }
+
+ fn map_links(&mut self) {
+ for note in self.notes.values() {
+ for link in ¬e.links {
+ if link.kind != LinkKind::Wiki {
+ continue;
+ }
+
+ if let Some((_, other)) = self.find_note_by_title(&link.href) {
+ self.graph.add_edge(note.index, other.index, ());
+ }
+ }
+ }
+ }
+
+ /// Locate a note by its filename/original name.
+ #[must_use]
+ pub fn find_note_by_name(&self, name: &str) -> Option<(ItemPathBuf, &Note)> {
+ for (path, note) in &self.notes {
+ if note.name == name {
+ return Some((path.clone(), note));
+ }
+ }
+
+ None
+ }
+
+ /// Locate a note by its title.
+ #[must_use]
+ pub fn find_note_by_title(&self, title: &str) -> Option<(ItemPathBuf, &Note)> {
+ for (path, note) in &self.notes {
+ if note.title() == title {
+ return Some((path.clone(), note));
+ }
+ }
+
+ None
+ }
+
+ /// Returns a iterator of all the [`Note`]'s that link to a given [`Note`].
+ pub fn get_note_references(&self, index: NodeIndex) -> impl Iterator<Item = &Note> {
+ self.graph
+ .edges(index)
+ .map(|edge| edge.target())
+ .filter_map(|id| self.graph.node_weight(id))
+ .filter_map(|path| self.notes.get(path))
+ }
+}
+
+/// Utility wrapper of [`Vault`] (basically [`Arc`] `<` [`RwLock`] `<` [`Vault`] `>>`)
+#[derive(Debug)]
+pub struct SharedVault {
+ vault: Arc<parking_lot::RwLock<Vault>>,
+}
+
+impl SharedVault {
+ /// Create a [`SharedVault`] from a [`VaultPathBuf`], reading the vault's contents from the disk.
+ ///
+ /// # Errors
+ ///
+ /// This will fail if its unable to do IO
+ /// or is unable to parse any of the supported Obsidian types.
+ pub fn from_path(path: VaultPathBuf) -> Result<Self, VaultError> {
+ Ok(Self {
+ vault: Arc::new(parking_lot::RwLock::new(Vault::from_path(path)?)),
+ })
+ }
+
+ /// Take a read reference to the innter [`Vault`].
+ pub fn read(&self) -> parking_lot::RwLockReadGuard<'_, Vault> {
+ self.vault.read()
+ }
+
+ /// Clears the currrent Vault and reloads it from disk.
+ ///
+ /// # Errors
+ ///
+ /// This will fail if its unable to do IO
+ /// or is unable to parse any of the supported Obsidian types.
+ pub fn reload(&mut self) -> Result<(), VaultError> {
+ self.vault.write().reload()
+ }
+
+ /// Locate a note by its filename/original name.
+ #[must_use]
+ pub fn find_note_by_name(&self, name: &str) -> Option<(ItemPathBuf, Note)> {
+ self.read()
+ .find_note_by_name(name)
+ .map(|(path, note)| (path, note.clone()))
+ }
+
+ /// Locate a note by its title.
+ #[must_use]
+ pub fn find_note_by_title(&self, title: &str) -> Option<(ItemPathBuf, Note)> {
+ self.read()
+ .find_note_by_title(title)
+ .map(|(path, note)| (path, note.clone()))
+ }
+}
+
+impl Clone for SharedVault {
+ fn clone(&self) -> Self {
+ Self {
+ vault: self.vault.clone(),
+ }
+ }
+}
diff --git a/sable/Cargo.toml b/sable/Cargo.toml
new file mode 100644
index 0000000..2b09bb6
+[package]
+name = "sable"
+version = "0.1.0"
+edition = "2024"
+
+description = "a static site generator using obsidian"
+authors = ["wayver <[email protected]>"]
+
+workspace = ".."
+
+[dependencies]
+sable-core = { path = "../sable-core" }
+sable-renderer = { path = "../sable-renderer" }
+sable-vault = { path = "../sable-vault" }
+
+axum.workspace = true
+camino.workspace = true
+clap.workspace = true
+http-body-util.workspace = true
+humantime.workspace = true
+miette.workspace = true
+notify-debouncer-full.workspace = true
+thiserror.workspace = true
+tokio.workspace = true
+tower-http.workspace = true
+tracing.workspace = true
+tracing-subscriber.workspace = true
+
+[build-dependencies]
+vergen-gitcl.workspace = true
diff --git a/sable/README.md b/sable/README.md
new file mode 100644
index 0000000..161956d
+# sable
diff --git a/sable/build.rs b/sable/build.rs
new file mode 100644
index 0000000..6b160f0
+fn main() {
+ vergen_gitcl::Emitter::default()
+ .add_instructions(
+ &vergen_gitcl::GitclBuilder::all_git().expect("failed to build vergen_gitcl"),
+ )
+ .expect("failed to add vergen_gitcl to vergen_gitcl::emitter")
+ .emit()
+ .expect("failed to run vergen_gitcl::emit");
+}
diff --git a/sable/src/command/build.rs b/sable/src/command/build.rs
new file mode 100644
index 0000000..7b79ffc
+use std::{path::PathBuf, time::Instant};
+
+use camino::Utf8PathBuf;
+use sable_core::config::Config;
+use sable_renderer::{
+ Renderer, RendererError,
+ templates::{Templates, TemplatesError},
+};
+use sable_vault::{SharedVault, VaultError, VaultPathBuf};
+
+#[derive(Debug, miette::Diagnostic, thiserror::Error)]
+pub enum BuildError {
+ #[error("source directory does not exist")]
+ MissingSource,
+ #[error("templates directory does not exist")]
+ MissingTemplates,
+ #[error("failed to canonicalize source path")]
+ Canonicalization(#[source] std::io::Error),
+ #[error("path `{1}` is not valid utf-8")]
+ PathNotUtf8(#[source] camino::FromPathBufError, PathBuf),
+ #[error("failed to load templates")]
+ TemplatesLoad(#[source] TemplatesError),
+ #[error("failed to construct renderer")]
+ RendererInit(#[source] RendererError),
+ #[error("failed to load vault")]
+ VaultLoad(#[source] VaultError),
+ // #[error("failed to build custom asset")]
+ // CustomAsset(#[source] CustomAssetError),
+}
+
+#[derive(clap::Args)]
+pub struct BuildArgs {
+ /// The directory containing Tera templates
+ #[arg(long, short)]
+ pub templates: Option<PathBuf>,
+
+ /// The directory of the Obsidian vault
+ #[arg(long, short)]
+ pub src: Option<PathBuf>,
+
+ /// The directory to render the website to
+ #[arg(long, short)]
+ pub dest: Option<PathBuf>,
+}
+
+impl BuildArgs {
+ pub fn run(self, config: &Config) -> Result<(), BuildError> {
+ let then = Instant::now();
+
+ let dest = self.dest.unwrap_or_else(|| config.build.clone());
+ let src = self.src.unwrap_or_else(|| config.vault.clone());
+ let templates = self.templates.unwrap_or_else(|| config.templates.clone());
+
+ if !src.exists() {
+ return Err(BuildError::MissingSource);
+ }
+ if !templates.exists() {
+ return Err(BuildError::MissingTemplates);
+ }
+
+ let path = src.canonicalize().map_err(BuildError::Canonicalization)?;
+ let path = Utf8PathBuf::try_from(path.clone())
+ .map(VaultPathBuf)
+ .map_err(|err| BuildError::PathNotUtf8(err, path))?;
+
+ let vault = SharedVault::from_path(path).map_err(BuildError::VaultLoad)?;
+
+ let templates =
+ Templates::load(config, vault.clone()).map_err(BuildError::TemplatesLoad)?;
+
+ let _ = std::fs::create_dir_all(&dest);
+
+ tracing::info!(notes=%vault.read().notes.len(), tags=%vault.read().tags.len(), "loaded vault");
+
+ let renderer = Renderer::new(config.clone(), &crate::META_INFO, vault.clone(), templates);
+
+ renderer.render_all();
+ renderer.copy_assets();
+
+ // for asset in &config.assets {
+ // crate::assets::custom::run(&config, asset).map_err(BuildError::CustomAsset)?;
+ // }
+
+ tracing::info!(duration=%humantime::format_duration(then.elapsed()), "done");
+
+ Ok(())
+ }
+}
diff --git a/sable/src/command/mod.rs b/sable/src/command/mod.rs
new file mode 100644
index 0000000..916a029
+pub mod build;
+// pub mod serve;
+
+use sable_core::config::Config;
+
+use crate::command::{
+ build::{BuildArgs, BuildError},
+ // serve::{ServeArgs, ServeError},
+};
+
+#[derive(Debug, miette::Diagnostic, thiserror::Error)]
+pub enum CommandError {
+ #[error("failed to run build command: {0}")]
+ Build(#[source] BuildError),
+ // #[error("failed to run serve command: {0}")]
+ // Serve(#[source] ServeError),
+}
+
+#[derive(clap::Subcommand)]
+pub enum Command {
+ Build(BuildArgs),
+ // Serve(ServeArgs),
+}
+
+#[derive(clap::Parser)]
+#[command(version, author, about)]
+pub struct Cli {
+ #[command(subcommand)]
+ pub command: Command,
+}
+
+impl Cli {
+ pub async fn run(self, config: Config) -> Result<(), CommandError> {
+ match self.command {
+ Command::Build(args) => args.run(&config).map_err(CommandError::Build)?,
+ // Command::Serve(args) => args.run(config).await.map_err(CommandError::Serve)?,
+ }
+
+ Ok(())
+ }
+}
diff --git a/sable/src/command/serve.rs b/sable/src/command/serve.rs
new file mode 100644
index 0000000..4bfb5dc
+use std::{net::SocketAddr, path::PathBuf, time::Duration};
+
+use axum::{
+ Router,
+ body::Body,
+ extract::{Request, State},
+ handler::HandlerWithoutStateExt as _,
+ http::StatusCode,
+ response::{IntoResponse as _, Response},
+ routing::any,
+};
+use http_body_util::BodyExt as _;
+use notify_debouncer_full::{
+ DebounceEventResult, DebouncedEvent, new_debouncer,
+ notify::{self, RecursiveMode},
+};
+use tokio::sync::mpsc;
+use tower_http::services::ServeDir;
+
+use crate::{
+ assets::custom::CustomAssetError,
+ config::Config,
+ content::{
+ VaultPath,
+ vault::{Vault, VaultError},
+ },
+ renderer::{Renderer, RendererError},
+ templates::{Templates, TemplatesError},
+};
+
+static RELOAD_SCRIPT: &str = r#"<script>(() => { const ws = new WebSocket(`ws://${location.host}/__ws`); ws.onmessage = (() => location.reload(true)) })()</script>"#;
+
+#[derive(Debug, miette::Diagnostic, thiserror::Error)]
+pub enum ServeError {
+ #[error("source directory does not exist")]
+ MissingSource,
+ #[error("templates directory does not exist")]
+ MissingTemplates,
+ #[error("failed to create temporary directory for built assets")]
+ TempDirCreate(#[source] std::io::Error),
+ #[error("failed to create file system watcher debouncer")]
+ DebouncerNew(#[source] notify::Error),
+ #[error("failed to load path into file system watcher")]
+ LoadWatcher(#[source] notify::Error),
+ #[error("failed to bind to socket for server")]
+ BindSocket(#[source] std::io::Error),
+ #[error("failed to run server")]
+ Server(#[source] std::io::Error),
+}
+
+#[derive(clap::Args)]
+pub struct ServeArgs {
+ /// The directory containing Tera templates
+ #[arg(long, short)]
+ pub templates: Option<PathBuf>,
+
+ /// The directory of the Obsidian vault
+ #[arg(long, short)]
+ pub src: Option<PathBuf>,
+}
+
+impl ServeArgs {
+ pub async fn run(self, config: Config) -> Result<(), ServeError> {
+ if !config.vault.exists() {
+ return Err(ServeError::MissingSource);
+ }
+ if !config.templates.exists() {
+ return Err(ServeError::MissingTemplates);
+ }
+
+ let temp_dir = tempfile::tempdir().map_err(ServeError::TempDirCreate)?;
+
+ let (tx, rx) = mpsc::unbounded_channel();
+
+ tokio::spawn(builder(temp_dir.path().to_path_buf(), config.clone(), rx));
+
+ let mut debouncer = new_debouncer(Duration::from_secs(2), None, {
+ move |result: DebounceEventResult| match result {
+ Ok(events) => {
+ for event in events.into_iter() {
+ let _ = tx.send(event);
+ }
+ }
+ Err(errors) => {
+ for error in errors.iter() {
+ tracing::error!(err=%error, "file system watcher event has an error");
+ }
+ }
+ }
+ })
+ .map_err(ServeError::DebouncerNew)?;
+
+ debouncer
+ .watch(&config.r#static, RecursiveMode::Recursive)
+ .map_err(ServeError::LoadWatcher)?;
+ debouncer
+ .watch(&config.templates, RecursiveMode::Recursive)
+ .map_err(ServeError::LoadWatcher)?;
+ debouncer
+ .watch(&config.vault, RecursiveMode::Recursive)
+ .map_err(ServeError::LoadWatcher)?;
+
+ let listener = tokio::net::TcpListener::bind(("127.0.0.1", config.port))
+ .await
+ .map_err(ServeError::BindSocket)?;
+
+ let app = Router::new()
+ .route("/ws", any(handle_ws))
+ .fallback(handle_serve_dir)
+ .with_state(config);
+
+ tracing::info!("listening on {}", listener.local_addr().unwrap());
+
+ axum::serve(
+ listener,
+ app.into_make_service_with_connect_info::<SocketAddr>(),
+ )
+ .with_graceful_shutdown(shutdown_signal())
+ .await
+ .map_err(ServeError::Server)?;
+
+ Ok(())
+ }
+}
+
+async fn shutdown_signal() {
+ let ctrl_c = async {
+ tokio::signal::ctrl_c()
+ .await
+ .expect("failed to install Ctrl+C handler");
+ };
+
+ #[cfg(unix)]
+ let terminate = async {
+ tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
+ .expect("failed to install signal handler")
+ .recv()
+ .await;
+ };
+
+ #[cfg(not(unix))]
+ let terminate = std::future::pending::<()>();
+
+ tokio::select! {
+ _ = ctrl_c => {},
+ _ = terminate => {},
+ }
+}
+
+async fn handle_404() -> (StatusCode, &'static str) {
+ (StatusCode::NOT_FOUND, "Not found")
+}
+
+async fn handle_serve_dir(State(config): State<Config>, req: Request) -> Response {
+ let mut service = ServeDir::new(&config.build).not_found_service(handle_404.into_service());
+
+ let res = match service.try_call(req).await {
+ Ok(res) => res,
+ Err(err) => return (StatusCode::INTERNAL_SERVER_ERROR, format!("{}", err)).into_response(),
+ };
+
+ let (parts, body) = res.into_parts();
+
+ let mut body = match body.collect().await {
+ Ok(body) => body.to_bytes().to_vec(),
+ Err(err) => return (StatusCode::INTERNAL_SERVER_ERROR, format!("{}", err)).into_response(),
+ };
+
+ inject_reload_script(&mut body);
+
+ let body = Body::from(body);
+
+ Response::from_parts(parts, body)
+}
+
+async fn handle_ws() {}
+
+fn inject_reload_script(buf: &mut Vec<u8>) {
+ let Some(index) = buf.windows(7).position(|w| w == b"</body>") else {
+ return;
+ };
+
+ let mut injection = Vec::with_capacity(RELOAD_SCRIPT.len());
+
+ injection.extend_from_slice(RELOAD_SCRIPT.as_bytes());
+
+ buf.splice(index..index, injection);
+}
+
+#[derive(Debug, miette::Diagnostic, thiserror::Error)]
+pub enum BuildError {
+ #[error("failed to canonicalize source path")]
+ Canonicalization(#[source] std::io::Error),
+ #[error("failed to load templates")]
+ TemplatesLoad(#[source] TemplatesError),
+ #[error("failed to construct renderer")]
+ RendererInit(#[source] RendererError),
+ #[error("failed to load vault")]
+ VaultLoad(#[source] VaultError),
+ #[error("failed to build custom asset")]
+ CustomAsset(#[source] CustomAssetError),
+}
+
+// TODO: remove unwraps
+// TODO: rebuild parts only when needed (check event paths)
+async fn builder(
+ temp_path: PathBuf,
+ config: Config,
+ mut rx: mpsc::UnboundedReceiver<DebouncedEvent>,
+) {
+ let dest = temp_path.to_path_buf();
+ let src = config.vault.clone();
+
+ let path = src
+ .canonicalize()
+ .map(VaultPath)
+ .map_err(BuildError::Canonicalization)
+ .unwrap();
+
+ let _ = tokio::fs::create_dir_all(&dest).await;
+
+ while let Some(_event) = rx.blocking_recv() {
+ let templates = Templates::load(&config)
+ .await
+ .map_err(BuildError::TemplatesLoad)
+ .unwrap();
+
+ let vault = Vault::from_path(path.clone())
+ .await
+ .map_err(BuildError::VaultLoad)
+ .unwrap();
+
+ let renderer = Renderer::new(config.clone(), vault, templates)
+ .map_err(BuildError::RendererInit)
+ .unwrap();
+
+ renderer.render_all().await;
+ renderer.copy_assets().await;
+
+ for asset in &config.assets {
+ crate::assets::custom::run(&config, asset)
+ .map_err(BuildError::CustomAsset)
+ .unwrap();
+ }
+ }
+}
diff --git a/sable/src/main.rs b/sable/src/main.rs
new file mode 100644
index 0000000..1fd338c
+#![deny(rust_2018_idioms, unsafe_code)]
+#![warn(
+ absolute_paths_not_starting_with_crate,
+ ambiguous_associated_items,
+ anonymous_parameters,
+ arithmetic_overflow,
+ array_into_iter,
+ asm_sub_register,
+ bad_asm_style,
+ bindings_with_variant_name,
+ break_with_label_and_loop,
+ clashing_extern_declarations,
+ coherence_leak_check,
+ conflicting_repr_hints,
+ confusable_idents,
+ const_evaluatable_unchecked,
+ const_item_mutation,
+ dangling_pointers_from_temporaries,
+ dead_code,
+ deprecated_in_future,
+ deprecated_where_clause_location,
+ deprecated,
+ deref_into_dyn_supertrait,
+ deref_nullptr,
+ drop_bounds,
+ duplicate_macro_attributes,
+ dyn_drop,
+ ellipsis_inclusive_range_patterns,
+ enum_intrinsics_non_enums,
+ explicit_outlives_requirements,
+ exported_private_dependencies,
+ forbidden_lint_groups,
+ function_item_references,
+ future_incompatible,
+ ill_formed_attribute_input,
+ improper_ctypes_definitions,
+ improper_ctypes,
+ incomplete_features,
+ incomplete_include,
+ ineffective_unstable_trait_impl,
+ inline_no_sanitize,
+ invalid_atomic_ordering,
+ invalid_doc_attributes,
+ invalid_type_param_default,
+ invalid_value,
+ irrefutable_let_patterns,
+ keyword_idents,
+ large_assignments,
+ late_bound_lifetime_arguments,
+ legacy_derive_helpers,
+ macro_expanded_macro_exports_accessed_by_absolute_paths,
+ meta_variable_misuse,
+ missing_abi,
+ missing_copy_implementations,
+ missing_debug_implementations,
+ missing_docs,
+ mixed_script_confusables,
+ mutable_transmutes,
+ named_arguments_used_positionally,
+ named_asm_labels,
+ no_mangle_const_items,
+ no_mangle_generic_items,
+ non_ascii_idents,
+ non_camel_case_types,
+ non_fmt_panics,
+ non_shorthand_field_patterns,
+ non_snake_case,
+ non_upper_case_globals,
+ nonstandard_style,
+ noop_method_call,
+ overflowing_literals,
+ overlapping_range_endpoints,
+ path_statements,
+ patterns_in_fns_without_body,
+ proc_macro_derive_resolution_fallback,
+ pub_use_of_private_extern_crate,
+ redundant_semicolons,
+ repr_transparent_external_private_fields,
+ rust_2021_incompatible_closure_captures,
+ rust_2021_incompatible_or_patterns,
+ rust_2021_prefixes_incompatible_syntax,
+ rust_2021_prelude_collisions,
+ semicolon_in_expressions_from_macros,
+ soft_unstable,
+ stable_features,
+ text_direction_codepoint_in_comment,
+ text_direction_codepoint_in_literal,
+ trivial_bounds,
+ trivial_casts,
+ trivial_numeric_casts,
+ type_alias_bounds,
+ tyvar_behind_raw_pointer,
+ uncommon_codepoints,
+ unconditional_panic,
+ unconditional_recursion,
+ unexpected_cfgs,
+ uninhabited_static,
+ unknown_crate_types,
+ unnameable_test_items,
+ unreachable_code,
+ unreachable_patterns,
+ unreachable_pub,
+ unsafe_op_in_unsafe_fn,
+ unstable_features,
+ unstable_name_collisions,
+ unused_allocation,
+ unused_assignments,
+ unused_attributes,
+ unused_braces,
+ unused_comparisons,
+ unused_crate_dependencies,
+ unused_doc_comments,
+ unused_extern_crates,
+ unused_features,
+ unused_import_braces,
+ unused_imports,
+ unused_labels,
+ unused_lifetimes,
+ unused_macro_rules,
+ unused_macros,
+ unused_must_use,
+ unused_mut,
+ unused_parens,
+ unused_qualifications,
+ unused_unsafe,
+ unused_variables,
+ useless_deprecated,
+ while_true
+)]
+#![warn(
+ clippy::all,
+ clippy::await_holding_lock,
+ clippy::char_lit_as_u8,
+ clippy::checked_conversions,
+ clippy::cognitive_complexity,
+ clippy::dbg_macro,
+ clippy::debug_assert_with_mut_call,
+ clippy::disallowed_script_idents,
+ clippy::doc_link_with_quotes,
+ clippy::doc_markdown,
+ clippy::empty_enum,
+ clippy::empty_line_after_outer_attr,
+ clippy::empty_structs_with_brackets,
+ clippy::enum_glob_use,
+ clippy::equatable_if_let,
+ clippy::exit,
+ clippy::expl_impl_clone_on_copy,
+ clippy::explicit_deref_methods,
+ clippy::explicit_into_iter_loop,
+ clippy::fallible_impl_from,
+ clippy::filter_map_next,
+ clippy::flat_map_option,
+ clippy::float_cmp_const,
+ clippy::float_cmp,
+ clippy::float_equality_without_abs,
+ clippy::fn_params_excessive_bools,
+ clippy::fn_to_numeric_cast_any,
+ clippy::from_iter_instead_of_collect,
+ clippy::if_let_mutex,
+ clippy::implicit_clone,
+ clippy::imprecise_flops,
+ clippy::index_refutable_slice,
+ clippy::inefficient_to_string,
+ clippy::invalid_upcast_comparisons,
+ clippy::iter_not_returning_iterator,
+ clippy::large_digit_groups,
+ clippy::large_stack_arrays,
+ clippy::large_types_passed_by_value,
+ clippy::let_unit_value,
+ clippy::linkedlist,
+ clippy::lossy_float_literal,
+ clippy::macro_use_imports,
+ clippy::manual_ok_or,
+ clippy::map_err_ignore,
+ clippy::map_flatten,
+ clippy::map_unwrap_or,
+ clippy::match_same_arms,
+ clippy::match_wild_err_arm,
+ clippy::match_wildcard_for_single_variants,
+ clippy::mem_forget,
+ clippy::missing_const_for_fn,
+ clippy::missing_enforced_import_renames,
+ clippy::missing_errors_doc,
+ clippy::missing_panics_doc,
+ clippy::mut_mut,
+ clippy::mutex_integer,
+ clippy::needless_borrow,
+ clippy::needless_continue,
+ clippy::needless_for_each,
+ clippy::needless_pass_by_value,
+ clippy::negative_feature_names,
+ clippy::nonstandard_macro_braces,
+ clippy::nursery,
+ clippy::option_if_let_else,
+ clippy::option_option,
+ clippy::path_buf_push_overwrite,
+ clippy::pedantic,
+ clippy::print_stderr,
+ clippy::print_stdout,
+ clippy::ptr_as_ptr,
+ clippy::rc_mutex,
+ clippy::ref_option_ref,
+ clippy::rest_pat_in_fully_bound_structs,
+ clippy::same_functions_in_if_condition,
+ clippy::semicolon_if_nothing_returned,
+ clippy::shadow_unrelated,
+ clippy::similar_names,
+ clippy::single_match_else,
+ clippy::string_add_assign,
+ clippy::string_add,
+ clippy::string_lit_as_bytes,
+ clippy::string_to_string,
+ clippy::suspicious_operation_groupings,
+ clippy::todo,
+ clippy::trailing_empty_array,
+ clippy::trait_duplication_in_bounds,
+ clippy::trivially_copy_pass_by_ref,
+ clippy::unimplemented,
+ clippy::unnecessary_wraps,
+ clippy::unnested_or_patterns,
+ clippy::unseparated_literal_suffix,
+ clippy::unused_self,
+ clippy::use_debug,
+ clippy::use_self,
+ clippy::used_underscore_binding,
+ clippy::useless_let_if_seq,
+ clippy::useless_transmute,
+ clippy::verbose_file_reads,
+ clippy::wildcard_dependencies,
+ clippy::wildcard_imports,
+ clippy::zero_sized_map_values
+)]
+
+mod command;
+
+use clap::Parser;
+use miette::IntoDiagnostic as _;
+use sable_core::config::{Config, ConfigError};
+
+use crate::command::{Cli, CommandError};
+
+static META_INFO: sable_core::MetaInfo = sable_core::load_info!();
+
+#[derive(Debug, miette::Diagnostic, thiserror::Error)]
+pub enum AppError {
+ #[error("failed to load config: {0}")]
+ Config(#[source] ConfigError),
+ #[error("{0}")]
+ Command(#[source] CommandError),
+}
+
+#[tokio::main]
+async fn main() -> miette::Result<()> {
+ tracing_subscriber::fmt().init();
+
+ let config = Config::load().map_err(AppError::Config).into_diagnostic()?;
+
+ Cli::parse()
+ .run(config)
+ .await
+ .map_err(AppError::Command)
+ .into_diagnostic()?;
+
+ Ok(())
+}